feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -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>
);
});

View 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>
);
});

View File

@@ -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}</>;
};

View File

@@ -0,0 +1 @@
export * from "./base";

View File

@@ -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>
);
});

View 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>
);
});

View 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&apos;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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -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>
</>
);
});

View 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>
)}
/>
)}
</>
);
});

View File

@@ -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>
);
});

View 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>
)}
</>
);
});

View File

@@ -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>
</>
);
});

View 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;

View 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>
);
});

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
</>
);
};

View File

@@ -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}`;
}
};

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export * from "./common";
export * from "./root";

View File

@@ -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"} />}
</>
);
});

View File

@@ -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>
</>
);
});

View File

@@ -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;
};

View File

@@ -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>
);
});

View 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>
</>
);
});

View 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>
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
</>
);
});

View File

@@ -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>
);
})}
</>
);
});

View File

@@ -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>
</>
);
});

View 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>
</>
);
});

View 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>
</>
);
});

View 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>
);
});

View File

@@ -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>
</>
);
});

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
</>
);
});

View File

@@ -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")}</>}
/>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
</>
);
});

View 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>
</>
);
});

View 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} />
))}
</>
);
});