feat: init
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import type { Props } from "./confirm-workspace-member-remove";
|
||||
|
||||
export const ConfirmWorkspaceMemberRemove: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, onSubmit, userDetails } = props;
|
||||
// states
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsRemoving(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsRemoving(true);
|
||||
|
||||
await onSubmit();
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{currentUser?.id === userDetails.id
|
||||
? "Leave workspace?"
|
||||
: `Remove ${userDetails?.display_name}?`}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
{currentUser?.id === userDetails.id ? (
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to leave the workspace? You will no longer have access to this
|
||||
workspace. This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to remove member-{" "}
|
||||
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
|
||||
access to this workspace. This action cannot be undone.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
||||
{currentUser?.id === userDetails.id
|
||||
? isRemoving
|
||||
? "Leaving"
|
||||
: "Leave"
|
||||
: isRemoving
|
||||
? "Removing"
|
||||
: "Remove"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
140
apps/web/core/components/workspace/billing/comparison/base.tsx
Normal file
140
apps/web/core/components/workspace/billing/comparison/base.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import type { TPlanePlans } from "@/constants/plans";
|
||||
import { ComingSoonBadge, PLANE_PLANS, PLANS_LIST } from "@/constants/plans";
|
||||
// local imports
|
||||
import { PlanFeatureDetail } from "./feature-detail";
|
||||
|
||||
type TPlansComparisonBaseProps = {
|
||||
planeDetails: React.ReactNode;
|
||||
isSelfManaged: boolean;
|
||||
isCompareAllFeaturesSectionOpen: boolean;
|
||||
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const shouldRenderPlanDetail = (planKey: TPlanePlans) => {
|
||||
// Free plan is not required to be shown in the comparison
|
||||
if (planKey === "free") return false;
|
||||
// Plane one plan is not longer available
|
||||
if (planKey === "one") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const PlansComparisonBase = observer((props: TPlansComparisonBaseProps) => {
|
||||
const { planeDetails, isSelfManaged, isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen } = props;
|
||||
// plan details
|
||||
const { planDetails, planHighlights, planComparison } = PLANE_PLANS;
|
||||
const numberOfPlansToRender = Object.keys(planDetails).filter((planKey) =>
|
||||
shouldRenderPlanDetail(planKey as TPlanePlans)
|
||||
).length;
|
||||
|
||||
const getSubscriptionType = (planKey: TPlanePlans) => planDetails[planKey].id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`size-full px-2 overflow-x-auto horizontal-scrollbar scrollbar-sm transition-all duration-500 ease-out will-change-transform`}
|
||||
>
|
||||
<div className="max-w-full" style={{ minWidth: `${numberOfPlansToRender * 280}px` }}>
|
||||
<div className="h-full flex flex-col gap-y-10">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 sticky top-2 z-10 bg-custom-background-100 grid gap-3 text-sm font-medium even:bg-custom-background-90 transition-all duration-500 ease-out will-change-transform"
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<div className="col-span-1 p-3 space-y-0.5 text-base font-medium" />
|
||||
{planeDetails}
|
||||
</div>
|
||||
{/* Plan Headers */}
|
||||
<section className="flex-shrink-0">
|
||||
{/* Plan Highlights */}
|
||||
<div
|
||||
className="grid gap-3 py-1 text-sm text-custom-text-200 even:bg-custom-background-90 rounded-sm"
|
||||
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
|
||||
>
|
||||
<div className="col-span-1 p-3 text-base font-medium">Highlights</div>
|
||||
{Object.entries(planHighlights).map(
|
||||
([planKey, highlights]) =>
|
||||
shouldRenderPlanDetail(planKey as TPlanePlans) && (
|
||||
<div key={planKey} className="col-span-1 p-3">
|
||||
<ul className="list-disc space-y-1">
|
||||
{highlights.map((highlight, index) => (
|
||||
<li key={index}>{highlight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Comparison */}
|
||||
{isCompareAllFeaturesSectionOpen && (
|
||||
<>
|
||||
{planComparison.map((section, sectionIdx) => (
|
||||
<section key={sectionIdx} className="flex-shrink-0">
|
||||
<h2 className="flex gap-2 items-start text-lg font-semibold text-custom-text-300 mb-2 pl-2">
|
||||
{section.title} {section.comingSoon && <ComingSoonBadge />}
|
||||
</h2>
|
||||
<div className="border-t border-custom-border-200">
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<div
|
||||
key={featureIdx}
|
||||
className="grid gap-3 text-sm text-custom-text-200 even:bg-custom-background-90 rounded-sm"
|
||||
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
|
||||
>
|
||||
<div className="col-span-1 p-3 flex items-center text-base font-medium">
|
||||
<div className="w-full flex gap-2 items-start justify-between">
|
||||
{feature.title} {feature.comingSoon && <ComingSoonBadge />}
|
||||
</div>
|
||||
</div>
|
||||
{PLANS_LIST.map(
|
||||
(planKey) =>
|
||||
shouldRenderPlanDetail(planKey) && (
|
||||
<div
|
||||
key={planKey}
|
||||
className="col-span-1 p-3 flex items-center justify-center text-center"
|
||||
>
|
||||
<PlanFeatureDetail
|
||||
subscriptionType={getSubscriptionType(planKey)}
|
||||
data={
|
||||
isSelfManaged
|
||||
? (feature["self-hosted"]?.[planKey] ?? feature.cloud[planKey])
|
||||
: feature.cloud[planKey]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<div className="flex items-center justify-center gap-1 my-4 pb-2">
|
||||
<Button
|
||||
variant="link-neutral"
|
||||
onClick={() => {
|
||||
setIsCompareAllFeaturesSectionOpen(!isCompareAllFeaturesSectionOpen);
|
||||
}}
|
||||
className="hover:bg-custom-background-90"
|
||||
>
|
||||
{isCompareAllFeaturesSectionOpen ? "Collapse comparison" : "Compare all features"}
|
||||
{isCompareAllFeaturesSectionOpen ? <ArrowUp className="size-4" /> : <ArrowDown className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FC } from "react";
|
||||
import { CheckCircle2, Minus, MinusCircle } from "lucide-react";
|
||||
import type { EProductSubscriptionEnum } from "@plane/types";
|
||||
// plane imports
|
||||
import { getSubscriptionTextColor } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import type { TPlanFeatureData } from "@/constants/plans";
|
||||
|
||||
type TPlanFeatureDetailProps = {
|
||||
subscriptionType: EProductSubscriptionEnum;
|
||||
data: TPlanFeatureData;
|
||||
};
|
||||
|
||||
export const PlanFeatureDetail: FC<TPlanFeatureDetailProps> = (props) => {
|
||||
const { subscriptionType, data } = props;
|
||||
|
||||
if (data === null || data === undefined) {
|
||||
return <Minus className="size-4 text-custom-text-400" />;
|
||||
}
|
||||
if (data === true) {
|
||||
return <CheckCircle2 className={cn(getSubscriptionTextColor(subscriptionType), "size-4")} />;
|
||||
}
|
||||
if (data === false) {
|
||||
return <MinusCircle className="size-4 text-custom-text-400" />;
|
||||
}
|
||||
return <>{data}</>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./base";
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
userDetails: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const ConfirmWorkspaceMemberRemove: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, onSubmit, userDetails } = props;
|
||||
// states
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsRemoving(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsRemoving(true);
|
||||
|
||||
await onSubmit();
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{currentUser?.id === userDetails.id
|
||||
? "Leave workspace?"
|
||||
: `Remove ${userDetails?.display_name}?`}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
{currentUser?.id === userDetails.id ? (
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{t("workspace_settings.settings.members.leave_confirmation")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{/* TODO: Add translation here */}
|
||||
Are you sure you want to remove member-{" "}
|
||||
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
|
||||
access to this workspace. This action cannot be undone.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
||||
{currentUser?.id === userDetails.id
|
||||
? isRemoving
|
||||
? t("leaving")
|
||||
: t("leave")
|
||||
: isRemoving
|
||||
? t("removing")
|
||||
: t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
268
apps/web/core/components/workspace/create-workspace-form.tsx
Normal file
268
apps/web/core/components/workspace/create-workspace-form.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction, FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
ORGANIZATION_SIZE,
|
||||
RESTRICTED_URLS,
|
||||
WORKSPACE_TRACKER_ELEMENTS,
|
||||
WORKSPACE_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
type Props = {
|
||||
onSubmit?: (res: IWorkspace) => Promise<void>;
|
||||
defaultValues: {
|
||||
name: string;
|
||||
slug: string;
|
||||
organization_size: string;
|
||||
};
|
||||
setDefaultValues: Dispatch<SetStateAction<Pick<IWorkspace, "name" | "slug" | "organization_size">>>;
|
||||
secondaryButton?: React.ReactNode;
|
||||
primaryButtonText?: {
|
||||
loading: string;
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
setDefaultValues,
|
||||
secondaryButton,
|
||||
primaryButtonText = {
|
||||
loading: "workspace_creation.button.loading",
|
||||
default: "workspace_creation.button.default",
|
||||
},
|
||||
} = props;
|
||||
// states
|
||||
const [slugError, setSlugError] = useState(false);
|
||||
const [invalidSlug, setInvalidSlug] = useState(false);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { createWorkspace } = useWorkspace();
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
.workspaceSlugCheck(formData.slug)
|
||||
.then(async (res) => {
|
||||
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
|
||||
setSlugError(false);
|
||||
|
||||
await createWorkspace(formData)
|
||||
.then(async (res) => {
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.create,
|
||||
payload: { slug: formData.slug },
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_creation.toast.success.title"),
|
||||
message: t("workspace_creation.toast.success.message"),
|
||||
});
|
||||
|
||||
if (onSubmit) await onSubmit(res);
|
||||
})
|
||||
.catch(() => {
|
||||
captureError({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.create,
|
||||
payload: { slug: formData.slug },
|
||||
error: new Error("Error creating workspace"),
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_creation.toast.error.title"),
|
||||
message: t("workspace_creation.toast.error.message"),
|
||||
});
|
||||
});
|
||||
} else setSlugError(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_creation.toast.error.title"),
|
||||
message: t("workspace_creation.toast.error.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// when the component unmounts set the default values to whatever user typed in
|
||||
setDefaultValues(getValues());
|
||||
},
|
||||
[getValues, setDefaultValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="space-y-6 sm:space-y-9" onSubmit={handleSubmit(handleCreateWorkspace)}>
|
||||
<div className="space-y-6 sm:space-y-7">
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="workspaceName">
|
||||
{t("workspace_creation.form.name.label")}
|
||||
<span className="ml-0.5 text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"),
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: t("workspace_creation.errors.validation.name_length"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<Input
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setValue("name", e.target.value);
|
||||
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder={t("workspace_creation.form.name.placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="workspaceUrl">
|
||||
{t("workspace_creation.form.url.label")}
|
||||
<span className="ml-0.5 text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">{window && window.location.host}/</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
maxLength: {
|
||||
value: 48,
|
||||
message: t("workspace_creation.errors.validation.url_length"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Input
|
||||
id="workspaceUrl"
|
||||
type="text"
|
||||
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
|
||||
onChange={(e) => {
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
|
||||
else setInvalidSlug(true);
|
||||
onChange(e.target.value.toLowerCase());
|
||||
}}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.slug)}
|
||||
placeholder={t("workspace_creation.form.url.placeholder")}
|
||||
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<p className="-mt-3 text-sm text-red-500">{t("workspace_creation.errors.validation.url_already_taken")}</p>
|
||||
)}
|
||||
{invalidSlug && (
|
||||
<p className="text-sm text-red-500">{t("workspace_creation.errors.validation.url_alphanumeric")}</p>
|
||||
)}
|
||||
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<span>
|
||||
{t("workspace_creation.form.organization_size.label")}
|
||||
<span className="ml-0.5 text-red-500">*</span>
|
||||
</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="organization_size"
|
||||
control={control}
|
||||
rules={{ required: t("common.errors.required") }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||
<span className="text-custom-text-400">
|
||||
{t("workspace_creation.form.organization_size.placeholder")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||
input
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
{item}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.organization_size && (
|
||||
<span className="text-sm text-red-500">{errors.organization_size.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{secondaryButton}
|
||||
<Button
|
||||
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.CREATE_WORKSPACE_BUTTON}
|
||||
variant="primary"
|
||||
type="submit"
|
||||
size="md"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t(primaryButtonText.loading) : t(primaryButtonText.default)}
|
||||
</Button>
|
||||
{!secondaryButton && (
|
||||
<Button variant="neutral-primary" type="button" size="md" onClick={() => router.back()}>
|
||||
{t("common.go_back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
173
apps/web/core/components/workspace/delete-workspace-form.tsx
Normal file
173
apps/web/core/components/workspace/delete-workspace-form.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
import { WORKSPACE_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
// hooks
|
||||
import { cn } from "@plane/utils";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserSettings } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
data: IWorkspace | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
workspaceName: "",
|
||||
confirmDelete: "",
|
||||
};
|
||||
|
||||
export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
|
||||
const { data, onClose } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { deleteWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
const { getWorkspaceRedirectionUrl } = useWorkspace();
|
||||
const { fetchCurrentUserSettings } = useUserSettings();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
const canDelete = watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
|
||||
|
||||
const handleClose = () => {
|
||||
const timer = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!data || !canDelete) return;
|
||||
|
||||
await deleteWorkspace(data.slug)
|
||||
.then(async () => {
|
||||
await fetchCurrentUserSettings();
|
||||
handleClose();
|
||||
router.push(getWorkspaceRedirectionUrl());
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.delete,
|
||||
payload: { slug: data.slug },
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.general.delete_modal.success_title"),
|
||||
message: t("workspace_settings.settings.general.delete_modal.success_message"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_settings.settings.general.delete_modal.error_title"),
|
||||
message: t("workspace_settings.settings.general.delete_modal.error_message"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.delete,
|
||||
payload: { slug: data.slug },
|
||||
error: new Error("Error deleting workspace"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 grid place-items-center rounded-full size-12 sm:size-10 bg-red-500/20 text-red-100"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="size-5 text-red-600" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium">{t("workspace_settings.settings.general.delete_modal.title")}</h3>
|
||||
<p className="mt-1 text-sm text-custom-text-200">
|
||||
You are about to delete the workspace <span className="break-words font-semibold">{data?.name}</span>. If
|
||||
you confirm, you will lose access to all your work data in this workspace without any way to restore it.
|
||||
Tread very carefully.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-custom-text-200 mt-4">
|
||||
<p className="break-words text-sm ">Type in this workspace's name to continue.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workspaceName"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="workspaceName"
|
||||
name="workspaceName"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.workspaceName)}
|
||||
placeholder={data?.name}
|
||||
className="mt-2 w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-custom-text-200 mt-4">
|
||||
<p className="text-sm">
|
||||
For final confirmation, type{" "}
|
||||
<span className="font-medium text-custom-text-100">delete my workspace </span>
|
||||
below.
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmDelete"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="confirmDelete"
|
||||
name="confirmDelete"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.confirmDelete)}
|
||||
placeholder=""
|
||||
className="mt-2 w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" type="submit" disabled={!canDelete} loading={isSubmitting}>
|
||||
{isSubmitting ? t("deleting") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/workspace/invite-modal/actions.tsx
Normal file
64
apps/web/core/components/workspace/invite-modal/actions.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TInvitationModalActionsProps = {
|
||||
isInviteDisabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
handleClose: () => void;
|
||||
appendField: () => void;
|
||||
addMoreButtonText?: string;
|
||||
submitButtonText?: {
|
||||
default: string;
|
||||
loading: string;
|
||||
};
|
||||
cancelButtonText?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationModalActions: React.FC<TInvitationModalActionsProps> = observer((props) => {
|
||||
const {
|
||||
isInviteDisabled = false,
|
||||
isSubmitting = false,
|
||||
handleClose,
|
||||
appendField,
|
||||
addMoreButtonText,
|
||||
submitButtonText,
|
||||
cancelButtonText,
|
||||
className,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("mt-5 flex items-center justify-between gap-2", className)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 bg-transparent py-2 pr-3 text-xs font-medium text-custom-primary outline-custom-primary",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": isInviteDisabled,
|
||||
}
|
||||
)}
|
||||
onClick={appendField}
|
||||
disabled={isInviteDisabled}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{addMoreButtonText || t("common.add_more")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{cancelButtonText || t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} disabled={isInviteDisabled}>
|
||||
{isSubmitting
|
||||
? submitButtonText?.loading || t("workspace_settings.settings.members.modal.button_loading")
|
||||
: submitButtonText?.default || t("workspace_settings.settings.members.modal.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
114
apps/web/core/components/workspace/invite-modal/fields.tsx
Normal file
114
apps/web/core/components/workspace/invite-modal/fields.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control, FieldArrayWithId, FormState } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { ROLE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import type { InvitationFormValues } from "@/hooks/use-workspace-invitation";
|
||||
|
||||
type TInvitationFieldsProps = {
|
||||
workspaceSlug: string;
|
||||
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
|
||||
control: Control<InvitationFormValues>;
|
||||
formState: FormState<InvitationFormValues>;
|
||||
remove: (index: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationFields = observer((props: TInvitationFieldsProps) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
fields,
|
||||
control,
|
||||
formState: { errors },
|
||||
remove,
|
||||
className,
|
||||
} = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { workspaceInfoBySlug } = useUserPermissions();
|
||||
// derived values
|
||||
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug.toString())?.role;
|
||||
|
||||
return (
|
||||
<div className={cn("mb-3 space-y-4", className)}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="relative group mb-1 flex items-start justify-between gap-x-4 text-sm w-full">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.email`}
|
||||
rules={{
|
||||
required: t("workspace_settings.settings.members.modal.errors.required"),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: t("workspace_settings.settings.members.modal.errors.invalid"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<>
|
||||
<Input
|
||||
id={`emails.${index}.email`}
|
||||
name={`emails.${index}.email`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.emails?.[index]?.email)}
|
||||
placeholder={t("workspace_settings.settings.members.modal.placeholder")}
|
||||
className="w-full text-xs sm:text-sm"
|
||||
/>
|
||||
{errors.emails?.[index]?.email && (
|
||||
<span className="ml-1 text-xs text-red-500">{errors.emails?.[index]?.email?.message}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.role`}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
className="flex-grow w-24"
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, value]) => {
|
||||
if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
|
||||
return (
|
||||
<CustomSelect.Option key={key} value={parseInt(key)}>
|
||||
{value}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{fields.length > 1 && (
|
||||
<div className="flex-item flex w-6">
|
||||
<button type="button" className="place-items-center self-center rounded" onClick={() => remove(index)}>
|
||||
<X className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
30
apps/web/core/components/workspace/invite-modal/form.tsx
Normal file
30
apps/web/core/components/workspace/invite-modal/form.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
|
||||
type TInvitationFormProps = {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onSubmit: () => void;
|
||||
actions: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationForm = observer((props: TInvitationFormProps) => {
|
||||
const { title, description, children, actions, onSubmit, className } = props;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className={className}>
|
||||
<div className="space-y-4">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="text-sm text-custom-text-200">{description}</div>
|
||||
{children}
|
||||
</div>
|
||||
{actions}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
35
apps/web/core/components/workspace/logo.tsx
Normal file
35
apps/web/core/components/workspace/logo.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
logo: string | null | undefined;
|
||||
name: string | undefined;
|
||||
classNames?: string;
|
||||
};
|
||||
|
||||
export const WorkspaceLogo = observer((props: Props) => {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
|
||||
!props.logo && "rounded-md bg-[#026292] text-white"
|
||||
} ${props.classNames ? props.classNames : ""}`
|
||||
)}
|
||||
>
|
||||
{props.logo && props.logo !== "" ? (
|
||||
<img
|
||||
src={getFileURL(props.logo)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
|
||||
alt={t("aria_labels.projects_sidebar.workspace_logo")}
|
||||
/>
|
||||
) : (
|
||||
(props.name?.[0] ?? "...")
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronDown, LinkIcon, Trash2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomSelect, CustomMenu } from "@plane/ui";
|
||||
import { cn, copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
invitationId: string;
|
||||
};
|
||||
|
||||
export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
|
||||
const { invitationId } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [removeMemberModal, setRemoveMemberModal] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions, workspaceInfoBySlug } = useUserPermissions();
|
||||
const {
|
||||
workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
|
||||
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
|
||||
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role;
|
||||
// is the current logged in user admin
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
// role change access-
|
||||
// 1. user cannot change their own role
|
||||
// 2. only admin or member can change role
|
||||
// 3. user cannot change role of higher role
|
||||
const hasRoleChangeAccess = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const handleRemoveInvitation = async () => {
|
||||
if (!workspaceSlug || !invitationDetails) return;
|
||||
|
||||
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Invitation removed successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (!invitationDetails || !currentWorkspaceMemberInfo) return null;
|
||||
|
||||
const handleCopyText = () => {
|
||||
try {
|
||||
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
|
||||
copyTextToClipboard(inviteLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating invite link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("common.actions.copy_link"),
|
||||
icon: LinkIcon,
|
||||
shouldRender: !!invitationDetails.invite_link,
|
||||
},
|
||||
{
|
||||
key: "remove",
|
||||
action: () => {
|
||||
captureClick({
|
||||
elementName: MEMBER_TRACKER_ELEMENTS.WORKSPACE_INVITATIONS_LIST_CONTEXT_MENU,
|
||||
});
|
||||
setRemoveMemberModal(true);
|
||||
},
|
||||
title: t("common.remove"),
|
||||
icon: Trash2,
|
||||
shouldRender: isAdmin,
|
||||
className: "text-red-500",
|
||||
iconClassName: "text-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmWorkspaceMemberRemove
|
||||
isOpen={removeMemberModal}
|
||||
onClose={() => setRemoveMemberModal(false)}
|
||||
userDetails={{
|
||||
id: invitationDetails.id,
|
||||
display_name: `${invitationDetails.email}`,
|
||||
}}
|
||||
onSubmit={handleRemoveInvitation}
|
||||
/>
|
||||
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90 w-full h-full">
|
||||
<div className="flex items-center gap-x-4 gap-y-2">
|
||||
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
|
||||
{(invitationDetails.email ?? "?")[0]}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="cursor-default text-sm">{invitationDetails.email}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center justify-center rounded bg-yellow-500/20 px-2.5 py-1 text-center text-xs font-medium text-yellow-500">
|
||||
<p>{t("common.pending")}</p>
|
||||
</div>
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<div className="item-center flex gap-1 rounded px-2 py-0.5">
|
||||
<span
|
||||
className={`flex items-center rounded text-xs font-medium ${
|
||||
hasRoleChangeAccess ? "" : "text-custom-sidebar-text-400"
|
||||
}`}
|
||||
>
|
||||
{ROLE[invitationDetails.role]}
|
||||
</span>
|
||||
{hasRoleChangeAccess && (
|
||||
<span className="grid place-items-center">
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
value={invitationDetails.role}
|
||||
onChange={(value: EUserPermissions) => {
|
||||
if (!workspaceSlug || !value) return;
|
||||
|
||||
updateMemberInvitation(workspaceSlug.toString(), invitationDetails.id, {
|
||||
role: value,
|
||||
}).catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error || "An error occurred while updating member role. Please try again.",
|
||||
});
|
||||
});
|
||||
}}
|
||||
disabled={!hasRoleChangeAccess}
|
||||
placement="bottom-end"
|
||||
>
|
||||
{Object.keys(ROLE).map((key) => {
|
||||
if (currentWorkspaceRole && currentWorkspaceRole !== 20 && currentWorkspaceRole < parseInt(key))
|
||||
return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
|
||||
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
{isAdmin && (
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
179
apps/web/core/components/workspace/settings/member-columns.tsx
Normal file
179
apps/web/core/components/workspace/settings/member-columns.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { SuspendedUserIcon } from "@plane/propel/icons";
|
||||
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUser, IWorkspaceMember } from "@plane/types";
|
||||
// plane ui
|
||||
import { CustomSelect, PopoverMenu, cn } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web constants
|
||||
|
||||
export interface RowData {
|
||||
member: IWorkspaceMember;
|
||||
role: EUserPermissions;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
type NameProps = {
|
||||
rowData: RowData;
|
||||
workspaceSlug: string;
|
||||
isAdmin: boolean;
|
||||
currentUser: IUser | undefined;
|
||||
setRemoveMemberModal: (rowData: RowData) => void;
|
||||
};
|
||||
|
||||
type AccountTypeProps = {
|
||||
rowData: RowData;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const NameColumn: React.FC<NameProps> = (props) => {
|
||||
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
|
||||
// derived values
|
||||
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
|
||||
const isSuspended = rowData.is_active === false;
|
||||
|
||||
return (
|
||||
<Disclosure>
|
||||
{({}) => (
|
||||
<div className="relative group">
|
||||
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
|
||||
<div className="flex items-center gap-x-2 gap-y-2 flex-1">
|
||||
{isSuspended ? (
|
||||
<div className="bg-custom-background-80 rounded-full p-0.5">
|
||||
<SuspendedUserIcon className="h-4 w-4 text-custom-text-400" />
|
||||
</div>
|
||||
) : avatar_url && avatar_url.trim() !== "" ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${id}`}>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full capitalize text-white">
|
||||
<img
|
||||
src={getFileURL(avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
|
||||
alt={display_name || email}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/profile/${id}`}>
|
||||
<span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full capitalize text-white bg-gray-700">
|
||||
{(email ?? display_name ?? "?")[0]}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className={isSuspended ? "text-custom-text-400" : ""}>
|
||||
{first_name} {last_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isSuspended && (isAdmin || id === currentUser?.id) && (
|
||||
<PopoverMenu
|
||||
data={[""]}
|
||||
keyExtractor={(item) => item}
|
||||
popoverClassName="justify-end"
|
||||
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
render={() => (
|
||||
<div
|
||||
className="flex items-center gap-x-3 cursor-pointer"
|
||||
onClick={() => setRemoveMemberModal(rowData)}
|
||||
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
|
||||
>
|
||||
<Trash2 className="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) => {
|
||||
const { rowData, workspaceSlug } = props;
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const {
|
||||
workspace: { updateMember },
|
||||
} = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
const isCurrentUser = currentUser?.id === rowData.member.id;
|
||||
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const isRoleNonEditable = isCurrentUser || !isAdminRole;
|
||||
const isSuspended = rowData.is_active === false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSuspended ? (
|
||||
<div className="w-32 flex ">
|
||||
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
|
||||
Suspended
|
||||
</Pill>
|
||||
</div>
|
||||
) : isRoleNonEditable ? (
|
||||
<div className="w-32 flex ">
|
||||
<span>{ROLE[rowData.role]}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: "Role is required." }}
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={(value: EUserPermissions) => {
|
||||
if (!workspaceSlug) return;
|
||||
updateMember(workspaceSlug.toString(), rowData.member.id, {
|
||||
role: value as unknown as EUserPermissions, // Cast value to unknown first, then to EUserPermissions
|
||||
}).catch((err) => {
|
||||
console.log(err, "err");
|
||||
const error = err.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "An error occurred while updating member role. Please try again.",
|
||||
});
|
||||
});
|
||||
}}
|
||||
label={
|
||||
<div className="flex ">
|
||||
<span>{ROLE[rowData.role]}</span>
|
||||
</div>
|
||||
}
|
||||
buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`}
|
||||
className="rounded-md p-0 w-32"
|
||||
input
|
||||
>
|
||||
{Object.keys(ROLE).map((item) => (
|
||||
<CustomSelect.Option key={item} value={item as unknown as EUserPermissions}>
|
||||
{ROLE[item as unknown as keyof typeof ROLE]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { MEMBER_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspaceMember } from "@plane/types";
|
||||
import { Table } from "@plane/ui";
|
||||
// components
|
||||
import { MembersLayoutLoader } from "@/components/ui/loader/layouts/members-layout-loader";
|
||||
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions, useUserSettings } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { useMemberColumns } from "@/plane-web/components/workspace/settings/useMemberColumns";
|
||||
|
||||
type Props = {
|
||||
memberDetails: (IWorkspaceMember | null)[];
|
||||
};
|
||||
|
||||
export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
|
||||
const { memberDetails } = props;
|
||||
const { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal } = useMemberColumns();
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
workspace: { removeMemberFromWorkspace },
|
||||
} = useMember();
|
||||
const { leaveWorkspace } = useUserPermissions();
|
||||
const { getWorkspaceRedirectionUrl } = useWorkspace();
|
||||
const { fetchCurrentUserSettings } = useUserSettings();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
|
||||
const handleLeaveWorkspace = async () => {
|
||||
if (!workspaceSlug || !currentUser) return;
|
||||
|
||||
await leaveWorkspace(workspaceSlug.toString())
|
||||
.then(async () => {
|
||||
await fetchCurrentUserSettings();
|
||||
router.push(getWorkspaceRedirectionUrl());
|
||||
captureSuccess({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
captureError({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
error: err,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || t("something_went_wrong_please_try_again"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
if (!workspaceSlug || !memberId) return;
|
||||
|
||||
await removeMemberFromWorkspace(workspaceSlug.toString(), memberId).catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || t("something_went_wrong_please_try_again"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: string) => {
|
||||
if (memberId === currentUser?.id) await handleLeaveWorkspace();
|
||||
else await handleRemoveMember(memberId);
|
||||
};
|
||||
|
||||
// is the member current logged in user
|
||||
// const isCurrentUser = memberDetails?.member.id === currentUser?.id;
|
||||
// is the current logged in user admin
|
||||
// role change access-
|
||||
// 1. user cannot change their own role
|
||||
// 2. only admin or member can change role
|
||||
// 3. user cannot change role of higher role
|
||||
|
||||
if (isEmpty(columns)) return <MembersLayoutLoader />;
|
||||
|
||||
return (
|
||||
<div className="border-t border-custom-border-100 grid">
|
||||
{removeMemberModal && (
|
||||
<ConfirmWorkspaceMemberRemove
|
||||
isOpen={removeMemberModal.member.id.length > 0}
|
||||
onClose={() => setRemoveMemberModal(null)}
|
||||
userDetails={{
|
||||
id: removeMemberModal.member.id,
|
||||
display_name: removeMemberModal.member.display_name || "",
|
||||
}}
|
||||
onSubmit={() => handleRemove(removeMemberModal.member.id)}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
columns={columns ?? []}
|
||||
data={(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as any}
|
||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||
tHeadClassName="border-b border-custom-border-100"
|
||||
thClassName="text-left font-medium divide-x-0 text-custom-text-400"
|
||||
tBodyClassName="divide-y-0"
|
||||
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-custom-text-200"
|
||||
tHeadTrClassName="divide-x-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
104
apps/web/core/components/workspace/settings/members-list.tsx
Normal file
104
apps/web/core/components/workspace/settings/members-list.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { CountChip } from "@/components/common/count-chip";
|
||||
import { MembersSettingsLoader } from "@/components/ui/loader/settings/members";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// local imports
|
||||
import { WorkspaceInvitationsListItem } from "./invitations-list-item";
|
||||
import { WorkspaceMembersListItem } from "./members-list-item";
|
||||
|
||||
export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> = observer((props) => {
|
||||
const { searchQuery, isAdmin } = props;
|
||||
const [showPendingInvites, setShowPendingInvites] = useState<boolean>(true);
|
||||
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
workspace: {
|
||||
fetchWorkspaceMembers,
|
||||
fetchWorkspaceMemberInvitations,
|
||||
workspaceMemberIds,
|
||||
getFilteredWorkspaceMemberIds,
|
||||
getSearchedWorkspaceMemberIds,
|
||||
workspaceMemberInvitationIds,
|
||||
getSearchedWorkspaceInvitationIds,
|
||||
getWorkspaceMemberDetails,
|
||||
},
|
||||
} = useMember();
|
||||
const { t } = useTranslation();
|
||||
// fetching workspace invitations
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_MEMBERS_AND_MEMBER_INVITATIONS_${workspaceSlug.toString()}` : null,
|
||||
workspaceSlug
|
||||
? async () => {
|
||||
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
|
||||
await fetchWorkspaceMembers(workspaceSlug.toString());
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
|
||||
|
||||
// derived values
|
||||
const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : [];
|
||||
const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds;
|
||||
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
|
||||
const memberDetails = searchedMemberIds
|
||||
?.map((memberId) => getWorkspaceMemberDetails(memberId))
|
||||
.sort((a, b) => {
|
||||
if (a?.is_active && !b?.is_active) return -1;
|
||||
if (!a?.is_active && b?.is_active) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y-[0.5px] divide-custom-border-100 overflow-scroll ">
|
||||
{searchedMemberIds?.length !== 0 && <WorkspaceMembersListItem memberDetails={memberDetails ?? []} />}
|
||||
{searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && (
|
||||
<h4 className="mt-16 text-center text-sm text-custom-text-400">{t("no_matching_members")}</h4>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && searchedInvitationsIds && searchedInvitationsIds.length > 0 && (
|
||||
<Collapsible
|
||||
isOpen={showPendingInvites}
|
||||
onToggle={() => setShowPendingInvites((prev) => !prev)}
|
||||
buttonClassName="w-full"
|
||||
className=""
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<div className="flex">
|
||||
<h4 className="text-xl font-medium pt-2 pb-2">
|
||||
{t("workspace_settings.settings.members.pending_invites")}
|
||||
</h4>
|
||||
{searchedInvitationsIds && (
|
||||
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
|
||||
)}
|
||||
</div>{" "}
|
||||
<ChevronDown className={`h-5 w-5 transition-all ${showPendingInvites ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="ml-auto items-center gap-1.5 rounded-md bg-custom-background-100 py-1.5">
|
||||
{searchedInvitationsIds?.map((invitationId) => (
|
||||
<WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} />
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Pencil } from "lucide-react";
|
||||
// constants
|
||||
import {
|
||||
ORGANIZATION_SIZE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORKSPACE_TRACKER_EVENTS,
|
||||
WORKSPACE_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
import { copyUrlToClipboard, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { DeleteWorkspaceSection } from "@/plane-web/components/workspace/delete-workspace-section";
|
||||
|
||||
const defaultValues: Partial<IWorkspace> = {
|
||||
name: "",
|
||||
url: "",
|
||||
organization_size: "2-10",
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
export const WorkspaceDetails: FC = observer(() => {
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentWorkspace, updateWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<IWorkspace>({
|
||||
defaultValues: { ...defaultValues, ...currentWorkspace },
|
||||
});
|
||||
// derived values
|
||||
const workspaceLogo = watch("logo_url");
|
||||
|
||||
const onSubmit = async (formData: IWorkspace) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const payload: Partial<IWorkspace> = {
|
||||
name: formData.name,
|
||||
organization_size: formData.organization_size,
|
||||
};
|
||||
|
||||
await updateWorkspace(currentWorkspace.slug, payload)
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.update,
|
||||
payload: { slug: currentWorkspace.slug },
|
||||
});
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Workspace updated successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureError({
|
||||
eventName: WORKSPACE_TRACKER_EVENTS.update,
|
||||
payload: { slug: currentWorkspace.slug },
|
||||
error: err,
|
||||
});
|
||||
console.error(err);
|
||||
});
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleRemoveLogo = async () => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
await updateWorkspace(currentWorkspace.slug, {
|
||||
logo_url: "",
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Workspace picture removed successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "There was some error in deleting your profile picture. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
copyUrlToClipboard(`${currentWorkspace.slug}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Workspace URL copied to the clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspace) reset({ ...currentWorkspace });
|
||||
}, [currentWorkspace, reset]);
|
||||
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
if (!currentWorkspace)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo_url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<WorkspaceImageUploadModal
|
||||
isOpen={isImageUploadModalOpen}
|
||||
onClose={() => setIsImageUploadModalOpen(false)}
|
||||
handleRemove={handleRemoveLogo}
|
||||
onSuccess={(imageUrl) => {
|
||||
onChange(imageUrl);
|
||||
setIsImageUploadModalOpen(false);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className={`w-full md:pr-9 pr-4 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className="flex gap-5 border-b border-custom-border-100 pb-7 items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
|
||||
{workspaceLogo && workspaceLogo !== "" ? (
|
||||
<div className="relative mx-auto flex h-14 w-14">
|
||||
<img
|
||||
src={getFileURL(workspaceLogo)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-[#026292] p-4 uppercase text-white">
|
||||
{currentWorkspace?.name?.charAt(0) ?? "N"}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-lg font-semibold leading-6 mb:-my-5">{watch("name")}</div>
|
||||
<button type="button" onClick={handleCopyUrl} className="text-sm tracking-tight text-left">{`${
|
||||
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
|
||||
}/${currentWorkspace.slug}`}</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-left text-xs font-medium text-custom-primary-100"
|
||||
onClick={() => setIsImageUploadModalOpen(true)}
|
||||
>
|
||||
{workspaceLogo && workspaceLogo !== "" ? (
|
||||
<>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("workspace_settings.settings.general.edit_logo")}
|
||||
</>
|
||||
) : (
|
||||
t("workspace_settings.settings.general.upload_logo")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-8 flex flex-col gap-8">
|
||||
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-10 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">{t("workspace_settings.settings.general.name")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: t("workspace_settings.settings.general.errors.name.required"),
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: t("workspace_settings.settings.general.errors.name.max_length"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder={t("workspace_settings.settings.general.name")}
|
||||
className="w-full rounded-md font-medium"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 ">
|
||||
<h4 className="text-sm">{t("workspace_settings.settings.general.company_size")}</h4>
|
||||
<Controller
|
||||
name="organization_size"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ??
|
||||
t("workspace_settings.settings.general.errors.company_size.select_a_range")
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
{item}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 ">
|
||||
<h4 className="text-sm">{t("workspace_settings.settings.general.url")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
render={({ field: { onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={`${
|
||||
typeof window !== "undefined" &&
|
||||
window.location.origin.replace("http://", "").replace("https://", "")
|
||||
}/${currentWorkspace.slug}`}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
className="w-full"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Button
|
||||
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.UPDATE_WORKSPACE_BUTTON}
|
||||
variant="primary"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? t("updating") : t("workspace_settings.settings.general.update_workspace")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && <DeleteWorkspaceSection workspace={currentWorkspace} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
118
apps/web/core/components/workspace/sidebar/dropdown-item.tsx
Normal file
118
apps/web/core/components/workspace/sidebar/dropdown-item.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check, Settings, UserPlus } from "lucide-react";
|
||||
import { Menu } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { cn, getFileURL, getUserRole } from "@plane/utils";
|
||||
// plane web imports
|
||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
|
||||
|
||||
type TProps = {
|
||||
workspace: IWorkspace;
|
||||
activeWorkspace: IWorkspace | null;
|
||||
handleItemClick: () => void;
|
||||
handleWorkspaceNavigation: (workspace: IWorkspace) => void;
|
||||
handleClose: () => void;
|
||||
};
|
||||
const SidebarDropdownItem = observer((props: TProps) => {
|
||||
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={workspace.id}
|
||||
href={`/${workspace.slug}`}
|
||||
onClick={() => {
|
||||
handleWorkspaceNavigation(workspace);
|
||||
handleItemClick();
|
||||
}}
|
||||
className="w-full"
|
||||
id={workspace.id}
|
||||
>
|
||||
<Menu.Item
|
||||
as="div"
|
||||
className={cn("px-4 py-2", {
|
||||
"bg-custom-sidebar-background-90": workspace.id === activeWorkspace?.id,
|
||||
"hover:bg-custom-sidebar-background-90": workspace.id !== activeWorkspace?.id,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1 rounded p-1 text-sm text-custom-sidebar-text-100 ">
|
||||
<div className="flex items-center justify-start gap-2.5 w-[80%] relative">
|
||||
<span
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-base uppercase font-medium border-custom-border-200 ${
|
||||
!workspace?.logo_url && "rounded-md bg-[#026292] text-white"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
||||
alt={t("workspace_logo")}
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.[0] ?? "...")
|
||||
)}
|
||||
</span>
|
||||
<div className="w-[inherit]">
|
||||
<div
|
||||
className={`truncate text-left text-ellipsis text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200"}`}
|
||||
>
|
||||
{workspace.name}
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-300 flex gap-2 capitalize w-fit">
|
||||
<span>{getUserRole(workspace.role)?.toLowerCase() || "guest"}</span>
|
||||
<div className="w-1 h-1 bg-custom-text-300/50 rounded-full m-auto" />
|
||||
<span className="capitalize">{t("member", { count: workspace.total_members || 0 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{workspace.id === activeWorkspace?.id ? (
|
||||
<span className="flex-shrink-0 p-1">
|
||||
<Check className="h-5 w-5 text-custom-sidebar-text-100" />
|
||||
</span>
|
||||
) : (
|
||||
<SubscriptionPill workspace={workspace} />
|
||||
)}
|
||||
</div>
|
||||
{workspace.id === activeWorkspace?.id && (
|
||||
<>
|
||||
<div className="mt-2 mb-1 flex gap-2">
|
||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||
<Link
|
||||
href={`/${workspace.slug}/settings`}
|
||||
onClick={handleClose}
|
||||
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100 hover:shadow-sm hover:text-custom-text-200 text-custom-text-300 hover:border-custom-border-300 "
|
||||
>
|
||||
<Settings className="h-4 w-4 my-auto" />
|
||||
<span className="text-sm font-medium my-auto">{t("settings")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
|
||||
<Link
|
||||
href={`/${workspace.slug}/settings/members`}
|
||||
onClick={handleClose}
|
||||
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100 hover:shadow-sm hover:text-custom-text-200 text-custom-text-300 hover:border-custom-border-300 "
|
||||
>
|
||||
<UserPlus className="h-4 w-4 my-auto" />
|
||||
<span className="text-sm font-medium my-auto">
|
||||
{t("project_settings.members.invite_members.title")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export default SidebarDropdownItem;
|
||||
22
apps/web/core/components/workspace/sidebar/dropdown.tsx
Normal file
22
apps/web/core/components/workspace/sidebar/dropdown.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
// components
|
||||
import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher";
|
||||
import { UserMenuRoot } from "./user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "./workspace-menu-root";
|
||||
|
||||
export const SidebarDropdown = observer(() => {
|
||||
// hooks
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 w-full">
|
||||
<WorkspaceMenuRoot />
|
||||
{isAppRailEnabled && !shouldRenderAppRail && <WorkspaceAppSwitcher />}
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DragLocationHistory,
|
||||
ElementDragPayload,
|
||||
DropTargetRecord,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
|
||||
import { orderBy } from "lodash-es";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { DraftIcon, FavoriteFolderIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IFavorite, InstructionType } from "@plane/types";
|
||||
import { CustomMenu, DropIndicator, DragHandle } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { FavoriteRoot } from "./favorite-items";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
|
||||
import { NewFavoriteFolder } from "./new-fav-folder";
|
||||
|
||||
type Props = {
|
||||
isLastChild: boolean;
|
||||
favorite: IFavorite;
|
||||
handleRemoveFromFavorites: (favorite: IFavorite) => void;
|
||||
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
};
|
||||
|
||||
export const FavoriteFolder: React.FC<Props> = (props) => {
|
||||
const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
|
||||
// store hooks
|
||||
const { getGroupedFavorites } = useFavorite();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
// refs
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (favorite.children === undefined && workspaceSlug) {
|
||||
getGroupedFavorites(workspaceSlug.toString(), favorite.id);
|
||||
}
|
||||
}, [favorite.id, favorite.children, workspaceSlug, getGroupedFavorites]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
const initialData = { id: favorite.id, isGroup: true, isChild: false };
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="rounded flex gap-1 bg-custom-background-100 text-sm p-1 pr-2">
|
||||
<div className="size-5 grid place-items-center flex-shrink-0">
|
||||
<FavoriteFolderIcon />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}, // canDrag: () => isDraggable,
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => getCanDrop(source, favorite, false),
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = [];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 0,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ source, self, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDragging, favorite.id, isLastChild, favorite.id]);
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
return folderToRename ? (
|
||||
<NewFavoriteFolder
|
||||
setCreateNewFolder={setFolderToRename}
|
||||
actionType="rename"
|
||||
defaultName={favorite.name}
|
||||
favoriteId={favorite.id}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Disclosure key={`${favorite.id}`} ref={elementRef} defaultOpen={false}>
|
||||
{({ open }) => (
|
||||
<div
|
||||
// id={`sidebar-${projectId}-${projectListType}`}
|
||||
className={cn("relative", {
|
||||
"bg-custom-sidebar-background-80 opacity-60": isDragging,
|
||||
"border-[2px] border-custom-primary-100": instruction === "make-child",
|
||||
})}
|
||||
>
|
||||
{/* draggable drop top indicator */}
|
||||
<DropIndicator isVisible={instruction === "reorder-above"} />
|
||||
<div
|
||||
className={cn(
|
||||
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
|
||||
{
|
||||
"bg-custom-sidebar-background-90": isMenuActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* draggable indicator */}
|
||||
|
||||
<div className="flex-shrink-0 w-3 h-3 rounded-sm absolute left-0 hidden group-hover:flex justify-center items-center transition-colors bg-custom-background-90 cursor-pointer text-custom-text-200 hover:text-custom-text-100">
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Tooltip tooltipContent={`${favorite.name}`} position="right" className="ml-8" isMobile={isMobile}>
|
||||
<div className="flex-grow flex truncate">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex-grow flex items-center gap-1.5 text-left select-none w-full"
|
||||
>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"
|
||||
}
|
||||
position="top-end"
|
||||
disabled={isDragging}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": favorite.sort_order === null,
|
||||
"cursor-grabbing": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="size-5 grid place-items-center flex-shrink-0">
|
||||
<FavoriteFolderIcon />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={actionSectionRef}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</span>
|
||||
}
|
||||
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isMenuActive,
|
||||
}
|
||||
)}
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
|
||||
<span>Remove from favorites</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => setFolderToRename(favorite.id)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<DraftIcon className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
|
||||
<span>Rename Folder</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
aria-label={t(
|
||||
open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": open,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</>
|
||||
</div>
|
||||
{favorite.children && favorite.children.length > 0 && (
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5 mt-1 px-2">
|
||||
{orderBy(favorite.children, "sequence", "desc").map((child, index) => (
|
||||
<FavoriteRoot
|
||||
key={child.id}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
favorite={child}
|
||||
isLastChild={index === favorite.children.length - 1}
|
||||
parentId={favorite.id}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
handleDrop={handleDrop}
|
||||
/>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
)}
|
||||
{/* draggable drop bottom indicator */}
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { DragHandle } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
sort_order: number | null;
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const FavoriteItemDragHandle: FC<Props> = observer((props) => {
|
||||
const { sort_order, isDragging } = props;
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
|
||||
position="top-end"
|
||||
disabled={isDragging}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": sort_order === null,
|
||||
"cursor-grabbing": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreHorizontal, Star } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IFavorite } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
ref: React.MutableRefObject<HTMLDivElement | null>;
|
||||
isMenuActive: boolean;
|
||||
favorite: IFavorite;
|
||||
onChange: (value: boolean) => void;
|
||||
handleRemoveFromFavorites: (favorite: IFavorite) => void;
|
||||
};
|
||||
|
||||
export const FavoriteItemQuickAction: FC<Props> = observer((props) => {
|
||||
const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={ref}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
}
|
||||
menuButtonOnClick={() => onChange(!isMenuActive)}
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isMenuActive,
|
||||
}
|
||||
)}
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500 flex-shrink-0" />
|
||||
<span>Remove from favorites</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FavoriteItemTitle: FC<Props> = observer((props) => {
|
||||
const { href, title, icon } = props;
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (isMobile) toggleSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent={title} isMobile={isMobile} position="right" className="ml-8">
|
||||
<Link href={href} className="flex items-center gap-1.5 truncate w-full" draggable onClick={handleOnClick}>
|
||||
<span className="flex items-center justify-center size-5">{icon}</span>
|
||||
<span className="text-sm leading-5 font-medium flex-1 truncate">{title}</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
elementRef: React.RefObject<HTMLDivElement>;
|
||||
isMenuActive?: boolean;
|
||||
};
|
||||
|
||||
export const FavoriteItemWrapper: FC<Props> = (props) => {
|
||||
const { children, elementRef, isMenuActive = false } = props;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"group/project-item cursor-pointer relative group flex items-center justify-between w-full gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90",
|
||||
{
|
||||
"bg-custom-sidebar-background-90": isMenuActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
import type { IFavorite, TLogoProps } from "@plane/types";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// plane web constants
|
||||
import { FAVORITE_ITEM_ICONS, FAVORITE_ITEM_LINKS } from "@/plane-web/constants/sidebar-favorites";
|
||||
|
||||
export const getFavoriteItemIcon = (type: string, logo?: TLogoProps | undefined) => {
|
||||
const Icon = FAVORITE_ITEM_ICONS[type] || PageIcon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden group-hover:flex items-center justify-center size-5">
|
||||
<Icon className="flex-shrink-0 size-4 stroke-[1.5] m-auto" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center size-5 group-hover:hidden">
|
||||
{logo?.in_use ? (
|
||||
<Logo logo={logo} size={16} type={type === "project" ? "material" : "lucide"} />
|
||||
) : (
|
||||
<Icon className="flex-shrink-0 size-4 stroke-[1.5] m-auto" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateFavoriteItemLink = (workspaceSlug: string, favorite: IFavorite) => {
|
||||
const entityLinkDetails = FAVORITE_ITEM_LINKS[favorite.entity_type];
|
||||
|
||||
if (!entityLinkDetails) {
|
||||
console.error(`Unrecognized favorite entity type: ${favorite.entity_type}`);
|
||||
return `/${workspaceSlug}`;
|
||||
}
|
||||
|
||||
if (entityLinkDetails.itemLevel === "workspace") {
|
||||
return `/${workspaceSlug}/${entityLinkDetails.getLink(favorite)}`;
|
||||
} else if (entityLinkDetails.itemLevel === "project") {
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/${entityLinkDetails.getLink(favorite)}`;
|
||||
} else {
|
||||
return `/${workspaceSlug}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./favorite-item-drag-handle";
|
||||
export * from "./favorite-item-quick-action";
|
||||
export * from "./favorite-item-wrapper";
|
||||
export * from "./favorite-item-title";
|
||||
export * from "./helper";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./common";
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { observer } from "mobx-react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import type { IFavorite, InstructionType } from "@plane/types";
|
||||
import { DropIndicator } from "@plane/ui";
|
||||
// hooks
|
||||
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
|
||||
// local imports
|
||||
import { getCanDrop, getInstructionFromPayload } from "../favorites.helpers";
|
||||
import { FavoriteItemDragHandle, FavoriteItemQuickAction, FavoriteItemTitle, FavoriteItemWrapper } from "./common";
|
||||
|
||||
type Props = {
|
||||
isLastChild: boolean;
|
||||
parentId: string | undefined;
|
||||
workspaceSlug: string;
|
||||
favorite: IFavorite;
|
||||
handleRemoveFromFavorites: (favorite: IFavorite) => void;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
};
|
||||
|
||||
export const FavoriteRoot: FC<Props> = observer((props) => {
|
||||
// props
|
||||
const { isLastChild, parentId, workspaceSlug, favorite, handleRemoveFromFavorites, handleDrop } = props;
|
||||
// store hooks
|
||||
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
|
||||
//state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
|
||||
//ref
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleQuickAction = (value: boolean) => setIsMenuActive(value);
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
const initialData = { id: favorite.id, isGroup: false, isChild: !!parentId, parentId };
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: elementRef.current,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">
|
||||
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => getCanDrop(source, favorite, !!parentId),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementRef?.current, isDragging, isLastChild, favorite.id]);
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropIndicator isVisible={instruction === "reorder-above"} />
|
||||
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive}>
|
||||
<FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />
|
||||
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
|
||||
<FavoriteItemQuickAction
|
||||
favorite={favorite}
|
||||
ref={actionSectionRef}
|
||||
isMenuActive={isMenuActive}
|
||||
onChange={handleQuickAction}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
/>
|
||||
</FavoriteItemWrapper>
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronRight, FolderPlus } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IFavorite } from "@plane/types";
|
||||
// constants
|
||||
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web components
|
||||
import { FavoriteFolder } from "./favorite-folder";
|
||||
import { FavoriteRoot } from "./favorite-items";
|
||||
import type { TargetData } from "./favorites.helpers";
|
||||
import { getInstructionFromPayload } from "./favorites.helpers";
|
||||
import { NewFavoriteFolder } from "./new-fav-folder";
|
||||
|
||||
export const SidebarFavoritesMenu = observer(() => {
|
||||
// states
|
||||
const [createNewFolder, setCreateNewFolder] = useState<boolean | string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { groupedFavorites, deleteFavorite, removeFromFavoriteFolder, reOrderFavorite, moveFavoriteToFolder } =
|
||||
useFavorite();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local storage
|
||||
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>(IS_FAVORITE_MENU_OPEN, false);
|
||||
// derived values
|
||||
const isFavoriteMenuOpen = !!storedValue;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMoveToFolder = (sourceId: string, destinationId: string) => {
|
||||
moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
|
||||
parent: destinationId,
|
||||
}).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("failed_to_move_favorite"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
|
||||
const isFolder = self.data?.isGroup;
|
||||
const dropTargets = location?.current?.dropTargets ?? [];
|
||||
if (!dropTargets || dropTargets.length <= 0) return;
|
||||
const dropTarget =
|
||||
dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0];
|
||||
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
|
||||
if (!dropTarget || !dropTargetData) return;
|
||||
const instruction = getInstructionFromPayload(dropTarget, source, location);
|
||||
const parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
|
||||
const droppedFavId = instruction !== "make-child" ? dropTargetData.id : undefined;
|
||||
const sourceData = source.data as TargetData;
|
||||
|
||||
if (!sourceData.id) return;
|
||||
if (isFolder) {
|
||||
// handle move to a new parent folder if dropped on a folder
|
||||
if (parentId && parentId !== sourceData.parentId) {
|
||||
handleMoveToFolder(sourceData.id, parentId); /**parent id */
|
||||
}
|
||||
// handle reordering at root level
|
||||
if (droppedFavId) {
|
||||
if (instruction != "make-child") {
|
||||
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//handling reordering for favorites
|
||||
if (droppedFavId) {
|
||||
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
|
||||
}
|
||||
}
|
||||
|
||||
/**remove if dropped outside and source is a child */
|
||||
if (!parentId && sourceData.isChild) {
|
||||
handleRemoveFromFavoritesFolder(sourceData.id); /**parent null */
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (favorite: IFavorite) => {
|
||||
deleteFavorite(workspaceSlug.toString(), favorite.id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("favorite_removed_successfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
|
||||
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("failed_to_move_favorite"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(favoriteId: string, droppedFavId: string, edge: string | undefined) => {
|
||||
reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("failed_to_reorder_favorite"),
|
||||
});
|
||||
});
|
||||
},
|
||||
[workspaceSlug, reOrderFavorite, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
dropTargetForElements({
|
||||
element,
|
||||
onDragEnter: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragging(false);
|
||||
const sourceId = source?.data?.id as string | undefined;
|
||||
console.log({ sourceId });
|
||||
if (!sourceId || !groupedFavorites[sourceId].parent) return;
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementRef.current, isDragging]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Disclosure as="div" defaultOpen ref={containerRef}>
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"group/favorites-button w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90"
|
||||
)}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
|
||||
{
|
||||
"bg-custom-sidebar-background-80 opacity-60": isDragging,
|
||||
}
|
||||
)}
|
||||
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
|
||||
aria-label={t(
|
||||
isFavoriteMenuOpen
|
||||
? "aria_labels.projects_sidebar.close_favorites_menu"
|
||||
: "aria_labels.projects_sidebar.open_favorites_menu"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold">{t("favorites")}</span>
|
||||
</Disclosure.Button>
|
||||
<div className="flex items-center opacity-0 pointer-events-none group-hover/favorites-button:opacity-100 group-hover/favorites-button:pointer-events-auto">
|
||||
<Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
|
||||
onClick={() => {
|
||||
setCreateNewFolder(true);
|
||||
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
|
||||
}}
|
||||
aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
|
||||
>
|
||||
<FolderPlus className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
|
||||
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
|
||||
aria-label={t(
|
||||
isFavoriteMenuOpen
|
||||
? "aria_labels.projects_sidebar.close_favorites_menu"
|
||||
: "aria_labels.projects_sidebar.open_favorites_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-3 transition-all", {
|
||||
"rotate-90": isFavoriteMenuOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
show={isFavoriteMenuOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isFavoriteMenuOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col mt-0.5 gap-0.5" static>
|
||||
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
|
||||
{Object.keys(groupedFavorites).length === 0 ? (
|
||||
<>
|
||||
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">{t("no_favorites_yet")}</span>
|
||||
</>
|
||||
) : (
|
||||
orderBy(Object.values(groupedFavorites), "sequence", "desc")
|
||||
.filter((fav) => !fav.parent)
|
||||
.map((fav, index, { length }) => (
|
||||
<>
|
||||
{fav?.is_folder ? (
|
||||
<FavoriteFolder
|
||||
favorite={fav}
|
||||
isLastChild={index === length - 1}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
|
||||
handleDrop={handleDrop}
|
||||
/>
|
||||
) : (
|
||||
<FavoriteRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
favorite={fav}
|
||||
isLastChild={index === length - 1}
|
||||
parentId={undefined}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
handleDrop={handleDrop}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import type { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isChild: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
|
||||
* @param dropTarget dropTarget for which the instruction is required
|
||||
* @param source the dragging favorite data that is being dragged on the dropTarget
|
||||
* @param location location includes the data of all the dropTargets the source is being dragged on
|
||||
* @returns Instruction for dropTarget
|
||||
*/
|
||||
export const getInstructionFromPayload = (
|
||||
dropTarget: TDropTarget,
|
||||
source: TDropTarget,
|
||||
location: IPragmaticPayloadLocation
|
||||
): InstructionType | undefined => {
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
const sourceData = source?.data as TargetData;
|
||||
const allDropTargets = location?.current?.dropTargets;
|
||||
|
||||
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
|
||||
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
|
||||
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
|
||||
|
||||
if (!dropTargetData || !sourceData) return undefined;
|
||||
|
||||
let instruction = extractInstruction(dropTargetData)?.type;
|
||||
|
||||
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
|
||||
if (instruction === "instruction-blocked") {
|
||||
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
|
||||
}
|
||||
|
||||
// if source that is being dragged is a group. A group cannon be a child of any other favorite,
|
||||
// hence if current instruction is to be a child of dropTarget then reorder-above instead
|
||||
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
|
||||
|
||||
return instruction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This provides a boolean to indicate if the favorite can be dropped onto the droptarget
|
||||
* @param source
|
||||
* @param favorite
|
||||
* @param isCurrentChild if the dropTarget is a child
|
||||
* @returns
|
||||
*/
|
||||
export const getCanDrop = (source: TDropTarget, favorite: IFavorite | undefined, isCurrentChild: boolean) => {
|
||||
const sourceData = source?.data;
|
||||
|
||||
if (!sourceData) return false;
|
||||
|
||||
// a favorite cannot be dropped on to itself
|
||||
if (sourceData.id === favorite?.id) return false;
|
||||
|
||||
// if current dropTarget is a child and the favorite being dropped is a group then don't enable drop
|
||||
if (isCurrentChild && sourceData.isGroup) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane helpers
|
||||
// plane ui
|
||||
import { FavoriteFolderIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input } from "@plane/ui";
|
||||
// hooks
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
|
||||
type TForm = {
|
||||
name: string;
|
||||
entity_type: string;
|
||||
parent: string | null;
|
||||
project_id: string | null;
|
||||
is_folder: boolean;
|
||||
};
|
||||
type TProps = {
|
||||
setCreateNewFolder: (value: boolean | string | null) => void;
|
||||
actionType: "create" | "rename";
|
||||
defaultName?: string;
|
||||
favoriteId?: string;
|
||||
};
|
||||
export const NewFavoriteFolder = observer((props: TProps) => {
|
||||
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { addFavorite, updateFavorite, existingFolders } = useFavorite();
|
||||
|
||||
// ref
|
||||
const ref = useRef(null);
|
||||
|
||||
// form info
|
||||
const { handleSubmit, control, setValue, setFocus } = useForm<TForm>({
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
name: defaultName,
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddNewFolder: SubmitHandler<TForm> = (formData) => {
|
||||
if (existingFolders.includes(formData.name))
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("folder_already_exists"),
|
||||
});
|
||||
formData = {
|
||||
entity_type: "folder",
|
||||
is_folder: true,
|
||||
name: formData.name.trim(),
|
||||
parent: null,
|
||||
project_id: null,
|
||||
};
|
||||
|
||||
if (formData.name === "")
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("folder_name_cannot_be_empty"),
|
||||
});
|
||||
|
||||
addFavorite(workspaceSlug.toString(), formData)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("favorite_created_successfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
setCreateNewFolder(false);
|
||||
setValue("name", "");
|
||||
};
|
||||
|
||||
const handleRenameFolder: SubmitHandler<TForm> = (formData) => {
|
||||
if (!favoriteId) return;
|
||||
if (existingFolders.includes(formData.name))
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("folder_already_exists"),
|
||||
});
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
};
|
||||
|
||||
if (formData.name.trim() === "")
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("folder_name_cannot_be_empty"),
|
||||
});
|
||||
|
||||
updateFavorite(workspaceSlug.toString(), favoriteId, payload)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("favorite_updated_successfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
setCreateNewFolder(false);
|
||||
setValue("name", "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("name");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setCreateNewFolder(false);
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-[1px] px-2" ref={ref}>
|
||||
<FavoriteFolderIcon className="w-[16px]" />
|
||||
<form onSubmit={handleSubmit(actionType === "create" ? handleAddNewFolder : handleRenameFolder)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder={t("new_folder")}
|
||||
aria-label={t("aria_labels.projects_sidebar.enter_folder_name")}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
127
apps/web/core/components/workspace/sidebar/help-menu.tsx
Normal file
127
apps/web/core/components/workspace/sidebar/help-menu.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { HelpCircle, MessagesSquare, User } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { ProductUpdatesModal } from "@/components/global";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PlaneVersionNumber } from "@/plane-web/components/global";
|
||||
|
||||
export interface WorkspaceHelpSectionProps {
|
||||
setSidebarActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { config } = useInstance();
|
||||
const { isIntercomToggle, toggleIntercom } = useTransient();
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
|
||||
|
||||
const handleCrispWindowShow = () => {
|
||||
toggleIntercom(!isIntercomToggle);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
|
||||
<div className="relative flex flex-shrink-0 items-center gap-1 justify-evenly">
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center rounded-md p-1 outline-none text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90",
|
||||
{
|
||||
"bg-custom-background-90": isNeedHelpOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Tooltip tooltipContent="Help" isMobile={isMobile} disabled={isNeedHelpOpen}>
|
||||
<HelpCircle className="h-[18px] w-[18px] outline-none" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
|
||||
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
|
||||
onMenuClose={() => setIsNeedHelpOpen(false)}
|
||||
placement="top-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => window.open("https://go.plane.so/p-docs", "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs hover:bg-custom-background-80">
|
||||
<PageIcon className="h-3.5 w-3.5 text-custom-text-200" height={14} width={14} />
|
||||
<span className="text-xs">{t("documentation")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{config?.intercom_app_id && config?.is_intercom_enabled && (
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCrispWindowShow}
|
||||
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
<span className="text-xs">{t("message_support")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank", "noopener,noreferrer")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs hover:bg-custom-background-80">
|
||||
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
|
||||
<span className="text-xs">{t("contact_sales")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShortcutModal(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("keyboard_shortcuts")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProductUpdatesModalOpen(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("whats_new")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => window.open("https://go.plane.so/p-discord", "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs hover:bg-custom-background-80">
|
||||
<span className="text-xs">Discord</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="px-1 pt-2 mt-1 text-xs text-custom-text-200 border-t border-custom-border-200">
|
||||
<PlaneVersionNumber />
|
||||
</div>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
149
apps/web/core/components/workspace/sidebar/help-section.tsx
Normal file
149
apps/web/core/components/workspace/sidebar/help-section.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { ProductUpdatesModal } from "@/components/global";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PlaneVersionNumber } from "@/plane-web/components/global";
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
|
||||
|
||||
export interface WorkspaceHelpSectionProps {
|
||||
setSidebarActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => {
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { config } = useInstance();
|
||||
const { isIntercomToggle, toggleIntercom } = useTransient();
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
|
||||
|
||||
const handleCrispWindowShow = () => {
|
||||
toggleIntercom(!isIntercomToggle);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
|
||||
<div className="flex w-full items-center justify-between px-2 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12 flex-shrink-0">
|
||||
<div className="relative flex flex-shrink-0 items-center gap-1 justify-evenly">
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center rounded-md p-1 outline-none text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90",
|
||||
{
|
||||
"bg-custom-background-90": isNeedHelpOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Tooltip tooltipContent="Help" isMobile={isMobile} disabled={isNeedHelpOpen}>
|
||||
<HelpCircle className="h-[18px] w-[18px] outline-none" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
|
||||
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
|
||||
onMenuClose={() => setIsNeedHelpOpen(false)}
|
||||
placement="top-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => window.open("https://go.plane.so/p-docs", "_blank")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<PageIcon className="h-3.5 w-3.5 text-custom-text-200" height={14} width={14} />
|
||||
<span className="text-xs">{t("documentation")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{config?.intercom_app_id && config?.is_intercom_enabled && (
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCrispWindowShow}
|
||||
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
<span className="text-xs">{t("message_support")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
|
||||
<span className="text-xs">{t("contact_sales")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShortcutModal(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("keyboard_shortcuts")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProductUpdatesModalOpen(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("whats_new")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => window.open("https://go.plane.so/p-discord", "_blank")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<span className="text-xs">Discord</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="px-1 pt-2 mt-1 text-xs text-custom-text-200 border-t border-custom-border-200">
|
||||
<PlaneVersionNumber />
|
||||
</div>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="w-full flex-grow px-0.5">
|
||||
<WorkspaceEditionBadge />
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1 justify-evenly">
|
||||
<Tooltip tooltipContent={`${isCollapsed ? "Expand" : "Hide"}`} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-md p-1 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100"
|
||||
onClick={() => {
|
||||
if (sidebarPeek) toggleSidebarPeek(false);
|
||||
toggleSidebar();
|
||||
}}
|
||||
aria-label={t(
|
||||
isCollapsed
|
||||
? "aria_labels.projects_sidebar.expand_sidebar"
|
||||
: "aria_labels.projects_sidebar.collapse_sidebar"
|
||||
)}
|
||||
>
|
||||
<MoveLeft className={`size-4 duration-300 ${isCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
111
apps/web/core/components/workspace/sidebar/help-section/root.tsx
Normal file
111
apps/web/core/components/workspace/sidebar/help-section/root.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { HelpCircle, MessagesSquare, User } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProductUpdatesModal } from "@/components/global";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
// plane web components
|
||||
import { PlaneVersionNumber } from "@/plane-web/components/global";
|
||||
|
||||
export const HelpMenuRoot = observer(() => {
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { config } = useInstance();
|
||||
const { isIntercomToggle, toggleIntercom } = useTransient();
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
|
||||
|
||||
const handleCrispWindowShow = () => {
|
||||
toggleIntercom(!isIntercomToggle);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
|
||||
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: <HelpCircle className="size-5" />,
|
||||
isActive: isNeedHelpOpen,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
|
||||
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
|
||||
onMenuClose={() => setIsNeedHelpOpen(false)}
|
||||
placement="top-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => window.open("https://go.plane.so/p-docs", "_blank")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<PageIcon className="h-3.5 w-3.5 text-custom-text-200" height={14} width={14} />
|
||||
<span className="text-xs">{t("documentation")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{config?.intercom_app_id && config?.is_intercom_enabled && (
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCrispWindowShow}
|
||||
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
<span className="text-xs">{t("message_support")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => window.open("mailto:sales@plane.so", "_blank")}>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
|
||||
<span className="text-xs">{t("contact_sales")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShortcutModal(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("keyboard_shortcuts")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProductUpdatesModalOpen(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">{t("whats_new")}</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => window.open("https://go.plane.so/p-discord", "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded text-xs">
|
||||
<span className="text-xs">Discord</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="px-1 pt-2 mt-1 text-xs text-custom-text-200 border-t border-custom-border-200">
|
||||
<PlaneVersionNumber />
|
||||
</div>
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { EUserProjectRoles } from "@plane/types";
|
||||
// plane ui
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
export type TNavigationItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
access: EUserPermissions[] | EUserProjectRoles[];
|
||||
shouldRender: boolean;
|
||||
sortOrder: number;
|
||||
i18n_key: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type TProjectItemsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
additionalNavigationItems?: (workspaceSlug: string, projectId: string) => TNavigationItem[];
|
||||
};
|
||||
|
||||
export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, additionalNavigationItems } = props;
|
||||
const { workItem: workItemIdentifierFromRoute } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { getPartialProjectById } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const {
|
||||
issue: { getIssueIdByIdentifier, getIssueById },
|
||||
} = useIssueDetail();
|
||||
// pathname
|
||||
const pathname = usePathname();
|
||||
// derived values
|
||||
const workItemId = workItemIdentifierFromRoute
|
||||
? getIssueIdByIdentifier(workItemIdentifierFromRoute?.toString())
|
||||
: undefined;
|
||||
const workItem = workItemId ? getIssueById(workItemId) : undefined;
|
||||
const project = getPartialProjectById(projectId);
|
||||
// handlers
|
||||
const handleProjectClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const baseNavigation = useCallback(
|
||||
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
|
||||
{
|
||||
i18n_key: "sidebar.work_items",
|
||||
key: "work_items",
|
||||
name: "Work items",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
icon: WorkItemsIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.cycles",
|
||||
key: "cycles",
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
icon: CycleIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
shouldRender: project.cycle_view,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.modules",
|
||||
key: "modules",
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
icon: ModuleIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
shouldRender: project.module_view,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.views",
|
||||
key: "views",
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
icon: ViewsIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: project.issue_views_view,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.pages",
|
||||
key: "pages",
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
icon: PageIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: project.page_view,
|
||||
sortOrder: 5,
|
||||
},
|
||||
{
|
||||
i18n_key: "sidebar.intake",
|
||||
key: "intake",
|
||||
name: "Intake",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/intake`,
|
||||
icon: IntakeIcon,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
shouldRender: project.inbox_view,
|
||||
sortOrder: 6,
|
||||
},
|
||||
],
|
||||
[project]
|
||||
);
|
||||
|
||||
// memoized navigation items and adding additional navigation items
|
||||
const navigationItemsMemo = useMemo(() => {
|
||||
const navigationItems = (workspaceSlug: string, projectId: string): TNavigationItem[] => {
|
||||
const navItems = baseNavigation(workspaceSlug, projectId);
|
||||
|
||||
if (additionalNavigationItems) {
|
||||
navItems.push(...additionalNavigationItems(workspaceSlug, projectId));
|
||||
}
|
||||
|
||||
return navItems;
|
||||
};
|
||||
|
||||
// sort navigation items by sortOrder
|
||||
const sortedNavigationItems = navigationItems(workspaceSlug, projectId).sort(
|
||||
(a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||
);
|
||||
|
||||
return sortedNavigationItems;
|
||||
}, [workspaceSlug, projectId, baseNavigation, additionalNavigationItems]);
|
||||
|
||||
const isActive = useCallback(
|
||||
(item: TNavigationItem) => {
|
||||
// work item condition
|
||||
const workItemCondition = workItemId && workItem && !workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// epic condition
|
||||
const epicCondition = workItemId && workItem && workItem?.is_epic && workItem?.project_id === projectId;
|
||||
// is active
|
||||
const isWorkItemActive = item.key === "work_items" && workItemCondition;
|
||||
const isEpicActive = item.key === "epics" && epicCondition;
|
||||
// pathname condition
|
||||
const isPathnameActive = pathname.includes(item.href);
|
||||
// return
|
||||
return isWorkItemActive || isEpicActive || isPathnameActive;
|
||||
},
|
||||
[pathname, workItem, workItemId, projectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{navigationItemsMemo.map((item) => {
|
||||
if (!item.shouldRender) return;
|
||||
|
||||
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project.id);
|
||||
if (!hasAccess) return null;
|
||||
|
||||
return (
|
||||
<Link key={item.key} href={item.href} onClick={handleProjectClick}>
|
||||
<SidebarNavItem isActive={!!isActive(item)}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<item.icon className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`} />
|
||||
<span className="text-xs font-medium">{t(item.i18n_key)}</span>
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LinkIcon, Settings, Share2, LogOut, MoreHorizontal, ChevronRight } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
|
||||
import { PublishProjectModal } from "@/components/project/publish-project/modal";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { ProjectNavigationRoot } from "@/plane-web/components/sidebar";
|
||||
// local imports
|
||||
import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../../issues/issue-layouts/utils";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
handleCopyText: () => void;
|
||||
handleOnProjectDrop?: (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => void;
|
||||
projectListType: "JOINED" | "FAVORITES";
|
||||
disableDrag?: boolean;
|
||||
disableDrop?: boolean;
|
||||
isLastChild: boolean;
|
||||
renderInExtendedSidebar?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
projectId,
|
||||
handleCopyText,
|
||||
disableDrag,
|
||||
disableDrop,
|
||||
isLastChild,
|
||||
handleOnProjectDrop,
|
||||
projectListType,
|
||||
renderInExtendedSidebar = false,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { getPartialProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
|
||||
// states
|
||||
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
||||
const [publishModalOpen, setPublishModal] = useState(false);
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isProjectListOpen = getIsProjectListOpen(projectId);
|
||||
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
|
||||
// refs
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const projectRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId: URLProjectId } = useParams();
|
||||
const router = useRouter();
|
||||
// derived values
|
||||
const project = getPartialProjectById(projectId);
|
||||
// toggle project list open
|
||||
const setIsProjectListOpen = (value: boolean) => toggleProjectListOpen(projectId, value);
|
||||
// auth
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
project?.id
|
||||
);
|
||||
|
||||
const handleLeaveProject = () => {
|
||||
setLeaveProjectModal(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = projectRef.current;
|
||||
const dragHandleElement = dragHandleRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
canDrag: () => !disableDrag,
|
||||
dragHandle: dragHandleElement ?? undefined,
|
||||
getInitialData: () => ({ id: projectId, dragInstanceId: "PROJECTS" }),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
// Add a custom drag image
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="rounded flex items-center bg-custom-background-100 text-sm p-1 pr-2">
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
{project && <Logo logo={project?.logo_props} />}
|
||||
</div>
|
||||
<p className="truncate text-custom-sidebar-text-200">{project?.name}</p>
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) =>
|
||||
!disableDrop && source?.data?.id !== projectId && source?.data?.dragInstanceId === "PROJECTS",
|
||||
getData: ({ input, element }) => {
|
||||
const data = { id: projectId };
|
||||
|
||||
// attach instruction for last in list
|
||||
return attachInstruction(data, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 0,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
});
|
||||
},
|
||||
onDrag: ({ self }) => {
|
||||
const extractedInstruction = extractInstruction(self?.data)?.type;
|
||||
// check if the highlight is to be shown above or below
|
||||
setInstruction(
|
||||
extractedInstruction
|
||||
? extractedInstruction === "reorder-below" && isLastChild
|
||||
? "DRAG_BELOW"
|
||||
: "DRAG_OVER"
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source }) => {
|
||||
setInstruction(undefined);
|
||||
const extractedInstruction = extractInstruction(self?.data)?.type;
|
||||
const currentInstruction = extractedInstruction
|
||||
? extractedInstruction === "reorder-below" && isLastChild
|
||||
? "DRAG_BELOW"
|
||||
: "DRAG_OVER"
|
||||
: undefined;
|
||||
if (!currentInstruction) return;
|
||||
|
||||
const sourceId = source?.data?.id as string | undefined;
|
||||
const destinationId = self?.data?.id as string | undefined;
|
||||
|
||||
handleOnProjectDrop?.(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
|
||||
|
||||
highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [projectId, isLastChild, projectListType, handleOnProjectDrop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuActive) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isMenuActive]);
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
useEffect(() => {
|
||||
if (URLProjectId === project.id) setIsProjectListOpen(true);
|
||||
}, [URLProjectId]);
|
||||
|
||||
const handleItemClick = () => setIsProjectListOpen(!isProjectListOpen);
|
||||
return (
|
||||
<>
|
||||
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => setPublishModal(false)} />
|
||||
<LeaveProjectModal project={project} isOpen={leaveProjectModalOpen} onClose={() => setLeaveProjectModal(false)} />
|
||||
<Disclosure key={`${project.id}_${URLProjectId}`} defaultOpen={isProjectListOpen} as="div">
|
||||
<div
|
||||
id={`sidebar-${projectId}-${projectListType}`}
|
||||
className={cn("relative", {
|
||||
"bg-custom-sidebar-background-80 opacity-60": isDragging,
|
||||
})}
|
||||
ref={projectRef}
|
||||
>
|
||||
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
|
||||
<div
|
||||
className={cn(
|
||||
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
|
||||
{
|
||||
"bg-custom-sidebar-background-90": isMenuActive,
|
||||
}
|
||||
)}
|
||||
id={`${project?.id}`}
|
||||
>
|
||||
{!disableDrag && (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
project.sort_order === null ? t("join_the_project_to_rearrange") : t("drag_to_rearrange")
|
||||
}
|
||||
position="top-end"
|
||||
disabled={isDragging}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": project.sort_order === null,
|
||||
"cursor-grabbing": isDragging,
|
||||
flex: isMenuActive || renderInExtendedSidebar,
|
||||
}
|
||||
)}
|
||||
ref={dragHandleRef}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<>
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||
className="flex-grow flex truncate"
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
|
||||
aria-label={
|
||||
isProjectListOpen
|
||||
? t("aria_labels.projects_sidebar.close_project_menu")
|
||||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
</ControlLink>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={actionSectionRef}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
|
||||
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
}
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isMenuActive,
|
||||
}
|
||||
)}
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
|
||||
useCaptureForOutsideClick
|
||||
closeOnSelect
|
||||
onMenuClose={() => setIsMenuActive(false)}
|
||||
>
|
||||
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
|
||||
{/* {isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5 ", {
|
||||
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
|
||||
})}
|
||||
/>
|
||||
<span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)} */}
|
||||
|
||||
{/* publish project settings */}
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
|
||||
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
|
||||
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
|
||||
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</div>
|
||||
<div>{t("publish_project")}</div>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("copy_link")}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("archives")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 cursor-pointer">
|
||||
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{/* leave project */}
|
||||
{!isAuthorized && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={handleLeaveProject}
|
||||
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("leave_project")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
|
||||
aria-label={t(
|
||||
isProjectListOpen
|
||||
? "aria_labels.projects_sidebar.close_project_menu"
|
||||
: "aria_labels.projects_sidebar.open_project_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isProjectListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</>
|
||||
</div>
|
||||
<Transition
|
||||
show={isProjectListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isProjectListOpen && (
|
||||
<Disclosure.Panel as="div" className="relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5">
|
||||
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
|
||||
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
257
apps/web/core/components/workspace/sidebar/projects-list.tsx
Normal file
257
apps/web/core/components/workspace/sidebar/projects-list.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project/create-project-modal";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import type { TProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
import { SidebarProjectsListItem } from "./projects-list-item";
|
||||
|
||||
export const SidebarProjectsList: FC = observer(() => {
|
||||
// states
|
||||
const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(true);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
// auth
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("link_copied"),
|
||||
message: t("project_link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnProjectDrop = (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => {
|
||||
if (!sourceId || !destinationId || !workspaceSlug) return;
|
||||
if (sourceId === destinationId) return;
|
||||
|
||||
const joinedProjectsList: TProject[] = [];
|
||||
joinedProjects.map((projectId) => {
|
||||
const projectDetails = getPartialProjectById(projectId);
|
||||
if (projectDetails) joinedProjectsList.push(projectDetails);
|
||||
});
|
||||
|
||||
const sourceIndex = joinedProjects.indexOf(sourceId);
|
||||
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
|
||||
|
||||
if (joinedProjectsList.length <= 0) return;
|
||||
|
||||
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
|
||||
if (updatedSortOrder != undefined)
|
||||
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Implementing scroll animation styles based on the scroll length of the container
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const scrollTop = containerRef.current.scrollTop;
|
||||
setIsScrolled(scrollTop > 0);
|
||||
}
|
||||
};
|
||||
const currentContainerRef = containerRef.current;
|
||||
if (currentContainerRef) {
|
||||
currentContainerRef.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
return () => {
|
||||
if (currentContainerRef) {
|
||||
currentContainerRef.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll: ({ source }) => source?.data?.dragInstanceId === "PROJECTS",
|
||||
getAllowedAxis: () => "vertical",
|
||||
})
|
||||
);
|
||||
}, [containerRef]);
|
||||
|
||||
const toggleListDisclosure = (isOpen: boolean) => {
|
||||
setIsAllProjectsListOpen(isOpen);
|
||||
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
|
||||
};
|
||||
useEffect(() => {
|
||||
if (pathname.includes("projects")) {
|
||||
setIsAllProjectsListOpen(true);
|
||||
localStorage.setItem("isAllProjectsListOpen", "true");
|
||||
}
|
||||
}, [pathname]);
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
setToFavorite={false}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn({
|
||||
"border-t border-custom-sidebar-border-300": isScrolled,
|
||||
})}
|
||||
>
|
||||
<>
|
||||
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
|
||||
<div className="group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
|
||||
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
|
||||
aria-label={t(
|
||||
isAllProjectsListOpen
|
||||
? "aria_labels.projects_sidebar.close_projects_menu"
|
||||
: "aria_labels.projects_sidebar.open_projects_menu"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold">{t("projects")}</span>
|
||||
</Disclosure.Button>
|
||||
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
{isAuthorizedUser && (
|
||||
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={PROJECT_TRACKER_ELEMENTS.SIDEBAR_CREATE_PROJECT_TOOLTIP}
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsProjectModalOpen(true);
|
||||
}}
|
||||
aria-label={t("aria_labels.projects_sidebar.create_new_project")}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
|
||||
aria-label={t(
|
||||
isAllProjectsListOpen
|
||||
? "aria_labels.projects_sidebar.close_projects_menu"
|
||||
: "aria_labels.projects_sidebar.open_projects_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-3 transition-all", {
|
||||
"rotate-90": isAllProjectsListOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
show={isAllProjectsListOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{loader === "init-loader" && (
|
||||
<Loader className="w-full space-y-1.5">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Loader.Item key={index} height="28px" />
|
||||
))}
|
||||
</Loader>
|
||||
)}
|
||||
{isAllProjectsListOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
|
||||
<>
|
||||
{joinedProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</>
|
||||
|
||||
{isAuthorizedUser && joinedProjects?.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={PROJECT_TRACKER_ELEMENTS.SIDEBAR_CREATE_PROJECT_BUTTON}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-sm leading-5 font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 rounded-md"
|
||||
onClick={() => {
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
{t("add_project")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
95
apps/web/core/components/workspace/sidebar/quick-actions.tsx
Normal file
95
apps/web/core/components/workspace/sidebar/quick-actions.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddIcon } from "@plane/propel/icons";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
import { SidebarAddButton } from "@/components/sidebar/add-button";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web components
|
||||
import { AppSearch } from "@/plane-web/components/workspace/sidebar/app-search";
|
||||
|
||||
export const SidebarQuickActions = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
|
||||
const [isDraftButtonOpen, setIsDraftButtonOpen] = useState(false);
|
||||
// refs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const timeoutRef = useRef<any>();
|
||||
// router
|
||||
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug?.toString();
|
||||
// store hooks
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { joinedProjectIds } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// local storage
|
||||
const { storedValue, setValue } = useLocalStorage<Record<string, Partial<TIssue>>>("draftedIssue", {});
|
||||
// derived values
|
||||
const canCreateIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const disabled = joinedProjectIds.length === 0 || !canCreateIssue;
|
||||
const workspaceDraftIssue = workspaceSlug ? (storedValue?.[workspaceSlug] ?? undefined) : undefined;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// if enter before time out clear the timeout
|
||||
if (timeoutRef?.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsDraftButtonOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsDraftButtonOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const removeWorkspaceDraftIssue = () => {
|
||||
const draftIssues = storedValue ?? {};
|
||||
if (workspaceSlug && draftIssues[workspaceSlug]) delete draftIssues[workspaceSlug];
|
||||
setValue(draftIssues);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isDraftIssueModalOpen}
|
||||
onClose={() => setIsDraftIssueModalOpen(false)}
|
||||
data={workspaceDraftIssue ?? {}}
|
||||
onSubmit={() => removeWorkspaceDraftIssue()}
|
||||
fetchIssueDetails={false}
|
||||
isDraft
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<SidebarAddButton
|
||||
label={
|
||||
<>
|
||||
<AddIcon className="size-4" />
|
||||
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => toggleCreateIssueModal(true)}
|
||||
disabled={disabled}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON}
|
||||
/>
|
||||
<AppSearch />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
68
apps/web/core/components/workspace/sidebar/sidebar-item.tsx
Normal file
68
apps/web/core/components/workspace/sidebar/sidebar-item.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// SidebarItemBase.tsx
|
||||
"use client";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import type { IWorkspaceSidebarNavigationItem } from "@plane/constants";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
|
||||
type Props = {
|
||||
item: IWorkspaceSidebarNavigationItem;
|
||||
additionalRender?: (itemKey: string, workspaceSlug: string) => ReactNode;
|
||||
additionalStaticItems?: string[];
|
||||
};
|
||||
|
||||
export const SidebarItemBase: FC<Props> = observer(({ item, additionalRender, additionalStaticItems }) => {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
const { data } = useUser();
|
||||
|
||||
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (window.innerWidth < 768) toggleSidebar();
|
||||
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
|
||||
};
|
||||
|
||||
const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])];
|
||||
const slug = workspaceSlug?.toString() || "";
|
||||
|
||||
if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null;
|
||||
|
||||
const sidebarPreference = getNavigationPreferences(slug);
|
||||
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
|
||||
if (!isPinned && !staticItems.includes(item.key)) return null;
|
||||
|
||||
const itemHref =
|
||||
item.key === "your_work" && data?.id ? joinUrlPath(slug, item.href, data?.id) : joinUrlPath(slug, item.href);
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
|
||||
return (
|
||||
<Link href={itemHref} onClick={handleLinkClick}>
|
||||
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
{icon}
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={slug} />}
|
||||
{additionalRender?.(item.key, slug)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronRight, Ellipsis } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// store hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane-web imports
|
||||
import { SidebarItem } from "@/plane-web/components/workspace/sidebar/sidebar-item";
|
||||
|
||||
export const SidebarMenuItems = observer(() => {
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
|
||||
"is_workspace_menu_open",
|
||||
true
|
||||
);
|
||||
|
||||
// store hooks
|
||||
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
const { getNavigationPreferences } = useWorkspace();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
|
||||
const toggleListDisclosure = (isOpen: boolean) => {
|
||||
toggleWorkspaceMenu(isOpen);
|
||||
};
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
|
||||
<SidebarItem key={`static_${_index}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<Disclosure as="div" className="flex flex-col" defaultOpen={!!isWorkspaceMenuOpen}>
|
||||
<div className="group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
|
||||
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
|
||||
aria-label={t(
|
||||
isWorkspaceMenuOpen
|
||||
? "aria_labels.app_sidebar.close_workspace_menu"
|
||||
: "aria_labels.app_sidebar.open_workspace_menu"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold">{t("workspace")}</span>
|
||||
</Disclosure.Button>
|
||||
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
|
||||
aria-label={t(
|
||||
isWorkspaceMenuOpen
|
||||
? "aria_labels.app_sidebar.close_workspace_menu"
|
||||
: "aria_labels.app_sidebar.open_workspace_menu"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-3 transition-all", {
|
||||
"rotate-90": isWorkspaceMenuOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
show={!!isWorkspaceMenuOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isWorkspaceMenuOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
|
||||
<>
|
||||
{WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
|
||||
<SidebarItem key={`static_${_index}`} item={item} />
|
||||
))}
|
||||
{sortedNavigationItems.map((item, _index) => (
|
||||
<SidebarItem key={`dynamic_${_index}`} item={item} />
|
||||
))}
|
||||
<SidebarNavItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExtendedSidebar()}
|
||||
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
|
||||
id="extended-sidebar-toggle"
|
||||
aria-label={t(
|
||||
isExtendedSidebarOpened
|
||||
? "aria_labels.app_sidebar.close_extended_sidebar"
|
||||
: "aria_labels.app_sidebar.open_extended_sidebar"
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="flex-shrink-0 size-4" />
|
||||
<span>{isExtendedSidebarOpened ? "Hide" : "More"}</span>
|
||||
</button>
|
||||
</SidebarNavItem>
|
||||
</>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
export interface SidebarUserMenuItemProps {
|
||||
item: {
|
||||
key: string;
|
||||
href: string;
|
||||
access: EUserWorkspaceRoles[];
|
||||
labelTranslationKey: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Icon: any;
|
||||
};
|
||||
draftIssueCount: number;
|
||||
}
|
||||
|
||||
export const SidebarUserMenuItem: FC<SidebarUserMenuItemProps> = observer((props) => {
|
||||
const { item, draftIssueCount } = props;
|
||||
// nextjs hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// package hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
if (item.key === "drafts" && draftIssueCount === 0) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) return null;
|
||||
|
||||
const handleLinkClick = (itemKey: string) => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
captureClick({
|
||||
elementName: SIDEBAR_TRACKER_ELEMENTS.USER_MENU_ITEM,
|
||||
context: {
|
||||
destination: itemKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={item.href} onClick={() => handleLinkClick(item.key)}>
|
||||
<SidebarNavItem isActive={isActive}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<item.Icon className="size-4 flex-shrink-0" />
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{item.key === "notifications" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug.toString()} />}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
162
apps/web/core/components/workspace/sidebar/user-menu-root.tsx
Normal file
162
apps/web/core/components/workspace/sidebar/user-menu-root.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import type { Ref } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// icons
|
||||
import { LogOut, PanelLeftDashed, Settings } from "lucide-react";
|
||||
// ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { GOD_MODE_URL } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
|
||||
type Props = {
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer((props: Props) => {
|
||||
const { size = "sm" } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme();
|
||||
|
||||
const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail();
|
||||
const { data: currentUser } = useUser();
|
||||
const { signOut } = useUser();
|
||||
// derived values
|
||||
|
||||
const isUserInstanceAdmin = false;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local state
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right",
|
||||
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
|
||||
});
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle sidebar dropdown state when either menu is open
|
||||
useEffect(() => {
|
||||
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
// Update local state directly
|
||||
if (isUserMenuOpen !== open) {
|
||||
setIsUserMenuOpen(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Button
|
||||
className="grid place-items-center outline-none"
|
||||
ref={setReferenceElement}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
|
||||
>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={size === "sm" ? 24 : 28}
|
||||
shape="circle"
|
||||
className="!text-base"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-[21] mt-1 flex w-44 origin-top-left flex-col divide-y
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<Menu.Item as="div">
|
||||
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
{isEnabled && (
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={() => {
|
||||
if (sidebarPeek) toggleSidebarPeek(false);
|
||||
toggleAppRail();
|
||||
}}
|
||||
>
|
||||
<PanelLeftDashed className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{isUserInstanceAdmin && (
|
||||
<div className="p-2 pb-0">
|
||||
<Link href={GOD_MODE_URL}>
|
||||
<Menu.Item as="button" type="button" className="w-full">
|
||||
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
|
||||
{t("enter_god_mode")}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
59
apps/web/core/components/workspace/sidebar/user-menu.tsx
Normal file
59
apps/web/core/components/workspace/sidebar/user-menu.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { DraftIcon, HomeIcon, InboxIcon, YourWorkIcon } from "@plane/propel/icons";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useUserPermissions, useUser } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { SidebarUserMenuItem } from "./user-menu-item";
|
||||
|
||||
export const SidebarUserMenu = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const SIDEBAR_USER_MENU_ITEMS = [
|
||||
{
|
||||
key: "home",
|
||||
labelTranslationKey: "sidebar.home",
|
||||
href: `/${workspaceSlug.toString()}/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
key: "your-work",
|
||||
labelTranslationKey: "sidebar.your_work",
|
||||
href: `/${workspaceSlug.toString()}/profile/${currentUser?.id}/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: YourWorkIcon,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
labelTranslationKey: "sidebar.inbox",
|
||||
href: `/${workspaceSlug.toString()}/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
key: "drafts",
|
||||
labelTranslationKey: "sidebar.drafts",
|
||||
href: `/${workspaceSlug.toString()}/drafts/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: DraftIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{SIDEBAR_USER_MENU_ITEMS.map((item) => (
|
||||
<SidebarUserMenuItem key={item.key} item={item} draftIssueCount={draftIssueCount} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FC } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { MoreHorizontal, ArchiveIcon, ChevronRight, Settings } from "lucide-react";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// store hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
export type SidebarWorkspaceMenuHeaderProps = {
|
||||
isWorkspaceMenuOpen: boolean;
|
||||
toggleWorkspaceMenu: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const SidebarWorkspaceMenuHeader: FC<SidebarWorkspaceMenuHeaderProps> = observer((props) => {
|
||||
const { isWorkspaceMenuOpen, toggleWorkspaceMenu } = props;
|
||||
// state
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// refs
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const router = useRouter();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
// TODO: fix types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
return (
|
||||
<div className="flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded mt-2.5">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-sm font-semibold"
|
||||
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
|
||||
>
|
||||
<span>{t("workspace")}</span>
|
||||
</Disclosure.Button>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
ref={actionSectionRef}
|
||||
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded my-auto"
|
||||
onClick={() => {
|
||||
setIsMenuActive(!isMenuActive);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
}
|
||||
className={cn(
|
||||
"h-full flex items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto my-auto",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isMenuActive,
|
||||
}
|
||||
)}
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/projects/archives`)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("archives")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings`)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
className="sticky top-0 z-10 group/workspace-button px-0.5 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
|
||||
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
|
||||
>
|
||||
{" "}
|
||||
<span className="flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded hover:bg-custom-sidebar-background-80">
|
||||
<ChevronRight
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isWorkspaceMenuOpen,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
|
||||
|
||||
export type SidebarWorkspaceMenuItemProps = {
|
||||
item: {
|
||||
labelTranslationKey: string;
|
||||
key: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
access: EUserWorkspaceRoles[];
|
||||
};
|
||||
};
|
||||
|
||||
export const SidebarWorkspaceMenuItem: FC<SidebarWorkspaceMenuItemProps> = observer((props) => {
|
||||
const { item } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
// nextjs hooks
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = item.href === pathname;
|
||||
|
||||
return (
|
||||
<Link href={item.href} onClick={() => handleLinkClick()}>
|
||||
<SidebarNavItem isActive={isActive}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<item.Icon
|
||||
className={cn("size-4", {
|
||||
"rotate-180": item.key === "active_cycles",
|
||||
})}
|
||||
/>
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<UpgradeBadge />
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment, useState, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { ChevronDown, CirclePlus, LogOut, Mails } from "lucide-react";
|
||||
// ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { orderWorkspacesList, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
// plane web helpers
|
||||
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
|
||||
// components
|
||||
import { WorkspaceLogo } from "../logo";
|
||||
import SidebarDropdownItem from "./dropdown-item";
|
||||
|
||||
type WorkspaceMenuRootProps = {
|
||||
renderLogoOnly?: boolean;
|
||||
};
|
||||
|
||||
export const WorkspaceMenuRoot = observer((props: WorkspaceMenuRootProps) => {
|
||||
const { renderLogoOnly } = props;
|
||||
// store hooks
|
||||
const { toggleSidebar, toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
const { signOut } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
|
||||
// derived values
|
||||
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local state
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false);
|
||||
|
||||
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
|
||||
// TODO: fix workspaces list scroll
|
||||
|
||||
// Toggle sidebar dropdown state when either menu is open
|
||||
useEffect(() => {
|
||||
if (isWorkspaceMenuOpen) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isWorkspaceMenuOpen]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className={cn("relative h-full flex ", {
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
|
||||
})}
|
||||
>
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
// Update local state directly
|
||||
if (isWorkspaceMenuOpen !== open) {
|
||||
setIsWorkspaceMenuOpen(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderLogoOnly ? (
|
||||
<Menu.Button className="flex items-center justify-center size-8">
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<WorkspaceLogo
|
||||
logo={activeWorkspace?.logo_url}
|
||||
name={activeWorkspace?.name}
|
||||
classNames="size-8 rounded-md"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none ",
|
||||
{
|
||||
"justify-center text-center": renderLogoOnly,
|
||||
"justify-between flex-grow": !renderLogoOnly,
|
||||
}
|
||||
)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
|
||||
>
|
||||
<div className="flex-grow flex items-center gap-2 truncate">
|
||||
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
|
||||
<h4 className="truncate text-base font-medium text-custom-text-100">
|
||||
{activeWorkspace?.name ?? t("loading")}
|
||||
</h4>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
|
||||
{ "rotate-180": open }
|
||||
)}
|
||||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="trnsform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items as={Fragment}>
|
||||
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
|
||||
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
|
||||
{currentUser?.email}
|
||||
</span>
|
||||
{workspacesList ? (
|
||||
<div className="size-full flex flex-col items-start justify-start">
|
||||
{(activeWorkspace
|
||||
? [
|
||||
activeWorkspace,
|
||||
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
|
||||
]
|
||||
: workspacesList
|
||||
).map((workspace) => (
|
||||
<SidebarDropdownItem
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
activeWorkspace={activeWorkspace}
|
||||
handleItemClick={handleItemClick}
|
||||
handleWorkspaceNavigation={handleWorkspaceNavigation}
|
||||
handleClose={close}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
|
||||
{isWorkspaceCreationEnabled && (
|
||||
<Link href="/create-workspace" className="w-full">
|
||||
<Menu.Item
|
||||
as="div"
|
||||
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
>
|
||||
<CirclePlus className="size-4 flex-shrink-0" />
|
||||
{t("create_workspace")}
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
|
||||
<Menu.Item
|
||||
as="div"
|
||||
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
>
|
||||
<Mails className="h-4 w-4 flex-shrink-0" />
|
||||
{t("workspace_invites")}
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
|
||||
<div className="w-full">
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 flex-shrink-0" />
|
||||
{t("sign_out")}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { AnalyticsIcon, CycleIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// local imports
|
||||
import { SidebarWorkspaceMenuHeader } from "./workspace-menu-header";
|
||||
import { SidebarWorkspaceMenuItem } from "./workspace-menu-item";
|
||||
|
||||
export const SidebarWorkspaceMenu = observer(() => {
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// local storage
|
||||
const { setValue: toggleWorkspaceMenu, storedValue } = useLocalStorage<boolean>("is_workspace_menu_open", true);
|
||||
// derived values
|
||||
const isWorkspaceMenuOpen = !!storedValue;
|
||||
|
||||
const SIDEBAR_WORKSPACE_MENU_ITEMS = [
|
||||
{
|
||||
key: "projects",
|
||||
labelTranslationKey: "sidebar.projects",
|
||||
href: `/${workspaceSlug}/projects/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: ProjectIcon,
|
||||
},
|
||||
{
|
||||
key: "views",
|
||||
labelTranslationKey: "sidebar.views",
|
||||
href: `/${workspaceSlug}/workspace-views/all-issues/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
Icon: ViewsIcon,
|
||||
},
|
||||
{
|
||||
key: "active-cycles",
|
||||
labelTranslationKey: "sidebar.cycles",
|
||||
href: `/${workspaceSlug}/active-cycles/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: CycleIcon,
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
labelTranslationKey: "sidebar.analytics",
|
||||
href: `/${workspaceSlug}/analytics/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
Icon: AnalyticsIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Disclosure as="div" defaultOpen>
|
||||
<SidebarWorkspaceMenuHeader isWorkspaceMenuOpen={isWorkspaceMenuOpen} toggleWorkspaceMenu={toggleWorkspaceMenu} />
|
||||
<Transition
|
||||
show={isWorkspaceMenuOpen}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
{isWorkspaceMenuOpen && (
|
||||
<Disclosure.Panel as="div" className="flex flex-col mt-0.5 gap-0.5" static>
|
||||
{SIDEBAR_WORKSPACE_MENU_ITEMS.map((item) => (
|
||||
<SidebarWorkspaceMenuItem key={item.key} item={item} />
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { truncateText } from "@plane/utils";
|
||||
|
||||
type Props = { view: { key: string; i18n_label: string } };
|
||||
|
||||
export const GlobalDefaultViewListItem: React.FC<Props> = observer((props) => {
|
||||
const { view } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
|
||||
<Link href={`/${workspaceSlug}/workspace-views/${view.key}`}>
|
||||
<div className="relative flex w-full h-[52px] items-center justify-between rounded px-5 py-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="truncate text-sm font-medium leading-4">{truncateText(t(view.i18n_label), 75)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, LinkIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// ui
|
||||
import type { TStaticViewTypes } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
view: {
|
||||
key: TStaticViewTypes;
|
||||
i18n_label: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DefaultWorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, view } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const viewLink = `${workspaceSlug}/workspace-views/${view.key}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "View link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: LinkIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{t(item.title || "")}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspaceView } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// constants
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
|
||||
type Props = {
|
||||
data: IWorkspaceView;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteGlobalViewModal: React.FC<Props> = observer((props) => {
|
||||
const { data, isOpen, onClose } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { deleteGlobalView } = useGlobalView();
|
||||
const { t } = useTranslation();
|
||||
const handleClose = () => onClose();
|
||||
|
||||
const handleDeletion = async () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await deleteGlobalView(workspaceSlug.toString(), data.id)
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.delete,
|
||||
payload: {
|
||||
view_id: data.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
captureError({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.delete,
|
||||
payload: {
|
||||
view_id: data.id,
|
||||
},
|
||||
error: error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong while deleting the view. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDeleteLoading(false);
|
||||
handleClose();
|
||||
});
|
||||
|
||||
// remove filters from local storage
|
||||
localStorage.removeItem(`global_view_filters/${data.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDeletion}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title={t("workspace_views.delete_view.title")}
|
||||
content={<>{t("workspace_views.delete_view.content")}</>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
202
apps/web/core/components/workspace/views/form.tsx
Normal file
202
apps/web/core/components/workspace/views/form.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IWorkspaceView, IIssueFilters } from "@plane/types";
|
||||
import { EViewAccess, EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
|
||||
import { Input, TextArea } from "@plane/ui";
|
||||
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils";
|
||||
// components
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
|
||||
// plane web imports
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
import { AccessController } from "@/plane-web/components/views/access-controller";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<IWorkspaceView>) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
data?: IWorkspaceView;
|
||||
preLoadedData?: Partial<IWorkspaceView>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const DEFAULT_VALUES: Partial<IWorkspaceView> = {
|
||||
name: "",
|
||||
description: "",
|
||||
access: EViewAccess.PUBLIC,
|
||||
display_properties: getComputedDisplayProperties(),
|
||||
display_filters: getComputedDisplayFilters({
|
||||
layout: EIssueLayoutTypes.SPREADSHEET,
|
||||
order_by: "-created_at",
|
||||
}),
|
||||
};
|
||||
|
||||
export const WorkspaceViewForm: React.FC<Props> = observer((props) => {
|
||||
const { handleFormSubmit, handleClose, data, preLoadedData, workspaceSlug } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
const defaultValues = {
|
||||
...DEFAULT_VALUES,
|
||||
...preLoadedData,
|
||||
...data,
|
||||
};
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
getValues,
|
||||
} = useForm<IWorkspaceView>({
|
||||
defaultValues,
|
||||
});
|
||||
// derived values
|
||||
const workItemFilters: IIssueFilters = {
|
||||
richFilters: getValues("rich_filters"),
|
||||
displayFilters: getValues("display_filters"),
|
||||
displayProperties: getValues("display_properties"),
|
||||
kanbanFilters: undefined,
|
||||
};
|
||||
|
||||
const handleCreateUpdateView = async (formData: Partial<IWorkspaceView>) => {
|
||||
await handleFormSubmit(formData);
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{data ? t("view.update.label") : t("view.create.label")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: t("form.title.required"),
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("form.title.max_length", { length: 255 }),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder={t("common.title")}
|
||||
className="w-full text-base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
value={value}
|
||||
placeholder={t("common.description")}
|
||||
onChange={onChange}
|
||||
className="w-full text-base resize-none min-h-24"
|
||||
hasError={Boolean(errors?.description)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<AccessController control={control} />
|
||||
{/* display filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_filters"
|
||||
render={({ field: { onChange: onDisplayFiltersChange, value: displayFilters } }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_properties"
|
||||
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
|
||||
<FiltersDropdown title={t("common.display")}>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions.spreadsheet}
|
||||
displayFilters={displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
onDisplayFiltersChange({
|
||||
...displayFilters,
|
||||
...updatedDisplayFilter,
|
||||
});
|
||||
}}
|
||||
displayProperties={displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
||||
onDisplayPropertiesChange({
|
||||
...displayProperties,
|
||||
...updatedDisplayProperties,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="rich_filters"
|
||||
render={({ field: { onChange: onFiltersChange } }) => (
|
||||
<WorkspaceLevelWorkItemFiltersHOC
|
||||
entityId={data?.id}
|
||||
entityType={EIssuesStoreType.GLOBAL}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
isTemporary
|
||||
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
|
||||
showOnMount
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: workspaceViewWorkItemsFilter }) =>
|
||||
workspaceViewWorkItemsFilter && (
|
||||
<WorkItemFiltersRow filter={workspaceViewWorkItemsFilter} variant="modal" />
|
||||
)
|
||||
}
|
||||
</WorkspaceLevelWorkItemFiltersHOC>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? t("common.updating")
|
||||
: t("view.update.label")
|
||||
: isSubmitting
|
||||
? t("common.creating")
|
||||
: t("view.create.label")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
132
apps/web/core/components/workspace/views/header.tsx
Normal file
132
apps/web/core/components/workspace/views/header.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
DEFAULT_GLOBAL_VIEWS_LIST,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
GLOBAL_VIEW_TRACKER_ELEMENTS,
|
||||
GLOBAL_VIEW_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import type { TStaticViewTypes } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// helpers
|
||||
import { captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { DefaultWorkspaceViewQuickActions } from "./default-view-quick-action";
|
||||
import { CreateUpdateWorkspaceViewModal } from "./modal";
|
||||
import { WorkspaceViewQuickActions } from "./quick-action";
|
||||
|
||||
const ViewTab = observer((props: { viewId: string }) => {
|
||||
const { viewId } = props;
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
// store hooks
|
||||
const { getViewDetailsById } = useGlobalView();
|
||||
|
||||
const view = getViewDetailsById(viewId);
|
||||
|
||||
if (!view || !workspaceSlug || !globalViewId) return null;
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="relative">
|
||||
<WorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={view} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const DefaultViewTab = (props: {
|
||||
tab: {
|
||||
key: TStaticViewTypes;
|
||||
i18n_label: string;
|
||||
};
|
||||
}) => {
|
||||
const { tab } = props;
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
|
||||
if (!workspaceSlug || !globalViewId) return null;
|
||||
return (
|
||||
<div key={tab.key} ref={parentRef} className="relative">
|
||||
<DefaultWorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={tab} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GlobalViewsHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [createViewModal, setCreateViewModal] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { globalViewId } = useParams();
|
||||
// store hooks
|
||||
const { currentWorkspaceViews } = useGlobalView();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// bring the active view to the centre of the header
|
||||
useEffect(() => {
|
||||
if (globalViewId && currentWorkspaceViews) {
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.open,
|
||||
payload: {
|
||||
view_id: globalViewId,
|
||||
view_type: ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString())
|
||||
? "Default"
|
||||
: "Custom",
|
||||
},
|
||||
});
|
||||
const activeTabElement = document.querySelector(`#global-view-${globalViewId.toString()}`);
|
||||
if (activeTabElement && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const activeTabRect = activeTabElement.getBoundingClientRect();
|
||||
const diff = containerRect.right - activeTabRect.right;
|
||||
activeTabElement.scrollIntoView({ behavior: "smooth", inline: diff > 500 ? "center" : "nearest" });
|
||||
}
|
||||
}
|
||||
}, [globalViewId, currentWorkspaceViews, containerRef]);
|
||||
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.SECONDARY} className="min-h-[44px] z-[12] bg-custom-background-100">
|
||||
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full w-full items-center overflow-y-hidden overflow-x-auto horizontal-scrollbar scrollbar-sm"
|
||||
>
|
||||
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab, index) => (
|
||||
<DefaultViewTab key={`${tab.key}-${index}`} tab={tab} />
|
||||
))}
|
||||
|
||||
{currentWorkspaceViews?.map((viewId) => (
|
||||
<ViewTab key={viewId} viewId={viewId} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAuthorizedUser ? (
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={GLOBAL_VIEW_TRACKER_ELEMENTS.RIGHT_HEADER_ADD_BUTTON}
|
||||
className="sticky -right-4 flex flex-shrink-0 items-center justify-center border-transparent bg-custom-background-100 py-3 hover:border-custom-border-200 hover:text-custom-text-400"
|
||||
onClick={() => setCreateViewModal(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-custom-primary-200" />
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
143
apps/web/core/components/workspace/views/modal.tsx
Normal file
143
apps/web/core/components/workspace/views/modal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspaceView } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { WorkspaceViewForm } from "./form";
|
||||
|
||||
type Props = {
|
||||
data?: IWorkspaceView;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
preLoadedData?: Partial<IWorkspaceView>;
|
||||
};
|
||||
|
||||
export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, data, preLoadedData } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
// store hooks
|
||||
const { createGlobalView, updateGlobalView } = useGlobalView();
|
||||
const { resetExpression } = useWorkItemFilters();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateView = async (payload: Partial<IWorkspaceView>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const payloadData: Partial<IWorkspaceView> = {
|
||||
...payload,
|
||||
rich_filters: {
|
||||
...payload?.rich_filters,
|
||||
},
|
||||
};
|
||||
|
||||
await createGlobalView(workspaceSlug, payloadData)
|
||||
.then((res) => {
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.create,
|
||||
payload: {
|
||||
id: res.id,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "View created successfully.",
|
||||
});
|
||||
|
||||
router.push(`/${workspaceSlug}/workspace-views/${res.id}`);
|
||||
handleClose();
|
||||
})
|
||||
.catch(() => {
|
||||
captureError({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.create,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "View could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateView = async (payload: Partial<IWorkspaceView>) => {
|
||||
if (!workspaceSlug || !data) return;
|
||||
|
||||
const payloadData: Partial<IWorkspaceView> = {
|
||||
...payload,
|
||||
query: {
|
||||
...payload?.rich_filters,
|
||||
},
|
||||
};
|
||||
|
||||
await updateGlobalView(workspaceSlug, data.id, payloadData)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
resetExpression(EIssuesStoreType.GLOBAL, data.id, res.rich_filters);
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
id: res.id,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "View updated successfully.",
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
captureError({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
id: data.id,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "View could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<IWorkspaceView>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!data) await handleCreateView(formData);
|
||||
else await handleUpdateView(formData);
|
||||
};
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<WorkspaceViewForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
data={data}
|
||||
preLoadedData={preLoadedData}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/workspace/views/quick-action.tsx
Normal file
110
apps/web/core/components/workspace/views/quick-action.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspaceView } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useViewMenuItems } from "@/plane-web/components/views/helper";
|
||||
// local imports
|
||||
import { DeleteGlobalViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateWorkspaceViewModal } from "./modal";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
view: IWorkspaceView;
|
||||
};
|
||||
|
||||
export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, view } = props;
|
||||
// states
|
||||
const [updateViewModal, setUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
// store hooks
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// auth
|
||||
const isOwner = view?.owned_by === data?.id;
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
const viewLink = `${workspaceSlug}/workspace-views/${view.id}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "View link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
setDeleteViewModal,
|
||||
setCreateUpdateViewModal: setUpdateViewModal,
|
||||
handleOpenInNewTab,
|
||||
handleCopyText,
|
||||
isLocked: view.is_locked,
|
||||
workspaceSlug,
|
||||
viewId: view.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
|
||||
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
captureClick({
|
||||
elementName: GLOBAL_VIEW_TRACKER_ELEMENTS.QUICK_ACTIONS,
|
||||
});
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
88
apps/web/core/components/workspace/views/view-list-item.tsx
Normal file
88
apps/web/core/components/workspace/views/view-list-item.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { truncateText } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
// local imports
|
||||
import { DeleteGlobalViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateWorkspaceViewModal } from "./modal";
|
||||
|
||||
type Props = { viewId: string };
|
||||
|
||||
export const GlobalViewListItem: React.FC<Props> = observer((props) => {
|
||||
const { viewId } = props;
|
||||
// states
|
||||
const [updateViewModal, setUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getViewDetailsById } = useGlobalView();
|
||||
// derived data
|
||||
const view = getViewDetailsById(viewId);
|
||||
|
||||
if (!view) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
|
||||
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
|
||||
<Link href={`/${workspaceSlug}/workspace-views/${view.id}`}>
|
||||
<div className="relative flex h-[52px] w-full items-center justify-between rounded p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="truncate text-sm font-medium leading-4">{truncateText(view.name, 75)}</p>
|
||||
{view?.description && <p className="text-xs text-custom-text-200">{view.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
captureClick({
|
||||
elementName: GLOBAL_VIEW_TRACKER_ELEMENTS.LIST_ITEM,
|
||||
});
|
||||
setUpdateViewModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
<span>Edit View</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
captureClick({
|
||||
elementName: GLOBAL_VIEW_TRACKER_ELEMENTS.LIST_ITEM,
|
||||
});
|
||||
setDeleteViewModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<span>Delete View</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
38
apps/web/core/components/workspace/views/views-list.tsx
Normal file
38
apps/web/core/components/workspace/views/views-list.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { ViewListLoader } from "@/components/ui/loader/view-list-loader";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
// local imports
|
||||
import { GlobalViewListItem } from "./view-list-item";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const GlobalViewsList: React.FC<Props> = observer((props) => {
|
||||
const { searchQuery } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { fetchAllGlobalViews, currentWorkspaceViews, getSearchedViews } = useGlobalView();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `GLOBAL_VIEWS_LIST_${workspaceSlug.toString()}` : null,
|
||||
workspaceSlug ? () => fetchAllGlobalViews(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
if (!currentWorkspaceViews) return <ViewListLoader />;
|
||||
|
||||
const filteredViewsList = getSearchedViews(searchQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredViewsList?.map((viewId) => (
|
||||
<GlobalViewListItem key={viewId} viewId={viewId} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user