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,255 @@
"use client";
import React, { forwardRef, useEffect } from "react";
import { observer } from "mobx-react";
import { TwitterPicker } from "react-color";
import type { SubmitHandler } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react";
// plane imports
import { getRandomLabelColor, LABEL_COLOR_OPTIONS, PROJECT_SETTINGS_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 { IIssueLabel } from "@plane/types";
import { Input } from "@plane/ui";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
export type TLabelOperationsCallbacks = {
createLabel: (data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
updateLabel: (labelId: string, data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
};
type TCreateUpdateLabelInlineProps = {
labelForm: boolean;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean;
labelOperationsCallbacks: TLabelOperationsCallbacks;
labelToUpdate?: IIssueLabel;
onClose?: () => void;
};
const defaultValues: Partial<IIssueLabel> = {
name: "",
color: "rgb(var(--color-text-200))",
};
export const CreateUpdateLabelInline = observer(
forwardRef<HTMLDivElement, TCreateUpdateLabelInlineProps>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelOperationsCallbacks, labelToUpdate, onClose } = props;
// form info
const {
handleSubmit,
control,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
setFocus,
} = useForm<IIssueLabel>({
defaultValues,
});
const { t } = useTranslation();
const handleClose = () => {
setLabelForm(false);
reset(defaultValues);
if (onClose) onClose();
};
const handleLabelCreate: SubmitHandler<IIssueLabel> = async (formData) => {
if (isSubmitting) return;
await labelOperationsCallbacks
.createLabel(formData)
.then((res) => {
captureSuccess({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_created,
payload: {
name: res.name,
id: res.id,
},
});
handleClose();
reset(defaultValues);
})
.catch((error) => {
captureError({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_created,
payload: {
name: formData.name,
},
error,
});
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: error?.detail ?? error.error ?? t("common.something_went_wrong"),
});
reset(formData);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabel> = async (formData) => {
if (!labelToUpdate?.id || isSubmitting) return;
await labelOperationsCallbacks
.updateLabel(labelToUpdate.id, formData)
.then((res) => {
captureSuccess({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_updated,
payload: {
name: res.name,
id: res.id,
},
});
reset(defaultValues);
handleClose();
})
.catch((error) => {
captureError({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_updated,
payload: {
name: formData.name,
id: labelToUpdate.id,
},
error,
});
setToast({
title: "Oops!",
type: TOAST_TYPE.ERROR,
message: error?.error ?? t("project_settings.labels.toast.error"),
});
reset(formData);
});
};
const handleFormSubmit = (formData: IIssueLabel) => {
if (isUpdating) {
handleLabelUpdate(formData);
} else {
handleLabelCreate(formData);
}
};
/**
* For settings focus on name input
*/
useEffect(() => {
setFocus("name");
}, [setFocus, labelForm]);
useEffect(() => {
if (!labelToUpdate) return;
setValue("name", labelToUpdate.name);
setValue("color", labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000");
}, [labelToUpdate, setValue]);
useEffect(() => {
if (labelToUpdate) {
setValue("color", labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000");
return;
}
setValue("color", getRandomLabelColor());
}, [labelToUpdate, setValue]);
return (
<>
<div
ref={ref}
className={`flex w-full scroll-m-8 items-center gap-2 bg-custom-background-100 ${labelForm ? "" : "hidden"}`}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-4 w-4 rounded-full"
style={{
backgroundColor: watch("color"),
}}
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-full z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
colors={LABEL_COLOR_OPTIONS}
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Controller
control={control}
name="name"
rules={{
required: t("project_settings.labels.label_title_is_required"),
maxLength: {
value: 255,
message: t("project_settings.labels.label_max_char"),
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="labelName"
name="name"
type="text"
autoFocus
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder={t("project_settings.labels.label_title")}
className="w-full"
/>
)}
/>
</div>
<Button variant="neutral-primary" onClick={() => handleClose()} size="sm">
{t("cancel")}
</Button>
<Button
variant="primary"
onClick={(e) => {
e.preventDefault();
handleSubmit(handleFormSubmit)();
}}
size="sm"
loading={isSubmitting}
>
{isUpdating ? (isSubmitting ? t("updating") : t("update")) : isSubmitting ? t("adding") : t("add")}
</Button>
</div>
{errors.name?.message && <p className="p-0.5 pl-8 text-sm text-red-500">{errors.name?.message}</p>}
</>
);
})
);

View File

@@ -0,0 +1,88 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IIssueLabel } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useLabel } from "@/hooks/store/use-label";
type Props = {
isOpen: boolean;
onClose: () => void;
data: IIssueLabel | null;
};
export const DeleteLabelModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, data } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { deleteLabel } = useLabel();
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const handleClose = () => {
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
if (!workspaceSlug || !projectId || !data) return;
setIsDeleteLoading(true);
await deleteLabel(workspaceSlug.toString(), projectId.toString(), data.id)
.then(() => {
captureSuccess({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_deleted,
payload: {
name: data.name,
project_id: projectId,
},
});
handleClose();
})
.catch((err) => {
setIsDeleteLoading(false);
captureError({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.label_deleted,
payload: {
name: data.name,
project_id: projectId,
},
error: err,
});
const error = err?.error || "Label could not be deleted. Please try again.";
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error,
});
});
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading}
isOpen={isOpen}
title="Delete Label"
content={
<>
Are you sure you want to delete <span className="font-medium text-custom-text-100">{data?.name}</span>? This
will remove the label from all the work item and from any views where the label is being filtered upon.
</>
}
/>
);
});

View File

@@ -0,0 +1,5 @@
export * from "./create-update-label-inline";
export * from "./delete-label-modal";
export * from "./project-setting-label-group";
export * from "./project-setting-label-item";
export * from "./project-setting-label-list";

View File

@@ -0,0 +1,109 @@
"use client";
import type { MutableRefObject } from "react";
import { useRef, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
// plane helpers
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
// types
import type { IIssueLabel } from "@plane/types";
// ui
import { CustomMenu, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// components
import { LabelName } from "./label-name";
export interface ICustomMenuItem {
CustomIcon: LucideIcon;
onClick: (label: IIssueLabel) => void;
isVisible: boolean;
text: string;
key: string;
}
interface ILabelItemBlock {
label: IIssueLabel;
isDragging: boolean;
customMenuItems: ICustomMenuItem[];
handleLabelDelete: (label: IIssueLabel) => void;
isLabelGroup?: boolean;
dragHandleRef: MutableRefObject<HTMLButtonElement | null>;
disabled?: boolean;
draggable?: boolean;
}
export const LabelItemBlock = (props: ILabelItemBlock) => {
const {
label,
isDragging,
customMenuItems,
handleLabelDelete,
isLabelGroup,
dragHandleRef,
disabled = false,
draggable = true,
} = props;
// states
const [isMenuActive, setIsMenuActive] = useState(true);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<div className="group flex items-center">
<div className="flex items-center">
{!disabled && draggable && (
<DragHandle
className={cn("opacity-0 group-hover:opacity-100", {
"opacity-100": isDragging,
})}
ref={dragHandleRef}
/>
)}
<LabelName color={label.color} name={label.name} isGroup={isLabelGroup ?? false} />
</div>
{!disabled && (
<div
ref={actionSectionRef}
className={`absolute right-2.5 flex items-center gap-2 px-4 ${
isMenuActive || isLabelGroup
? "opacity-100"
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
} ${isLabelGroup && "-top-0.5"}`}
>
<CustomMenu ellipsis menuButtonOnClick={() => setIsMenuActive(!isMenuActive)} useCaptureForOutsideClick>
{customMenuItems.map(
({ isVisible, onClick, CustomIcon, text, key }) =>
isVisible && (
<CustomMenu.MenuItem key={key} onClick={() => onClick(label)}>
<span className="flex items-center justify-start gap-2">
<CustomIcon className="size-4" />
<span>{text}</span>
</span>
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
{!isLabelGroup && (
<div className="py-0.5">
<button
className="flex size-5 items-center justify-center rounded hover:bg-custom-background-80"
onClick={() => {
handleLabelDelete(label);
}}
data-ph-element={PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_DELETE_BUTTON}
>
<X className="size-3.5 flex-shrink-0 text-custom-sidebar-text-300" />
</button>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { Component } from "lucide-react";
interface ILabelName {
name: string;
color: string;
isGroup: boolean;
}
export const LabelName = (props: ILabelName) => {
const { name, color, isGroup } = props;
return (
<div className="flex items-center gap-3 pr-20">
{isGroup ? (
<Component className="h-3.5 w-3.5" color={color} />
) : (
<span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: color && color !== "" ? color : "#000",
}}
/>
)}
<h6 className="text-sm">{name}</h6>
</div>
);
};

View File

@@ -0,0 +1,170 @@
"use client";
import type { MutableRefObject } from "react";
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 } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { createRoot } from "react-dom/client";
// types
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { IIssueLabel, InstructionType } from "@plane/types";
// ui
import { DropIndicator } from "@plane/ui";
// components
import { useUserPermissions } from "@/hooks/store/user";
import { LabelName } from "./label-block/label-name";
import type { TargetData } from "./label-utils";
import { getCanDrop, getInstructionFromPayload } from "./label-utils";
type LabelDragPreviewProps = {
label: IIssueLabel;
isGroup: boolean;
};
export const LabelDragPreview = (props: LabelDragPreviewProps) => {
const { label, isGroup } = props;
return (
<div className="py-3 pl-2 pr-4 border-[1px] border-custom-border-200 bg-custom-background-100">
<LabelName name={label.name} color={label.color} isGroup={isGroup} />
</div>
);
};
type Props = {
label: IIssueLabel;
isGroup: boolean;
isChild: boolean;
isLastChild: boolean;
children: (
isDragging: boolean,
isDroppingInLabel: boolean,
dragHandleRef: MutableRefObject<HTMLButtonElement | null>
) => React.ReactNode;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
};
export const LabelDndHOC = observer((props: Props) => {
const { label, isGroup, isChild, isLastChild, children, onDrop } = props;
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const labelRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
const { allowPermissions } = useUserPermissions();
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
useEffect(() => {
const element = labelRef.current;
const dragHandleElement = dragHandleRef.current;
if (!element || !isEditable) return;
return combine(
draggable({
element,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: label?.id, parentId: label?.parent, isGroup, isChild }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(<LabelDragPreview label={label} isGroup={isGroup} />);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => getCanDrop(source, label, isChild),
getData: ({ input, element }) => {
const data = { id: label?.id, parentId: label?.parent, isGroup, isChild };
const blockedStates: InstructionType[] = [];
// if is currently a child then block make-child instruction
if (isChild) blockedStates.push("make-child");
// if is currently is not a last child then block reorder-below instruction
if (!isLastChild) blockedStates.push("reorder-below");
return attachInstruction(data, {
input,
element,
currentLevel: isChild ? 1 : 0,
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: ({ source, location }) => {
setInstruction(undefined);
const dropTargets = location?.current?.dropTargets ?? [];
if (isChild || !dropTargets || dropTargets.length <= 0) return;
// if the label is dropped on both a child and it's parent at the same time then get only the child's drop target
const dropTarget =
dropTargets.length > 1 ? dropTargets.find((target) => target?.data?.isChild) : dropTargets[0];
let parentId: string | null = null,
dropAtEndOfList = false;
const dropTargetData = dropTarget?.data as TargetData;
if (!dropTarget || !dropTargetData) return;
// get possible instructions for the dropTarget
const instruction = getInstructionFromPayload(dropTarget, source, location);
// if instruction is make child the set parentId as current dropTarget Id or else set it as dropTarget's parentId
parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
// if instruction is any other than make-child, i.e., reorder-above and reorder-below then set the droppedId as dropTarget's id
const droppedLabelId = instruction !== "make-child" ? dropTargetData.id : undefined;
// if instruction is to reorder-below that is enabled only for end of the last items in the list then dropAtEndOfList as true
if (instruction === "reorder-below") dropAtEndOfList = true;
const sourceData = source.data as TargetData;
if (sourceData.id) onDrop(sourceData.id as string, parentId, droppedLabelId, dropAtEndOfList);
},
})
);
}, [labelRef?.current, dragHandleRef?.current, label, isChild, isGroup, isLastChild, onDrop]);
const isMakeChild = instruction == "make-child";
return (
<div ref={labelRef}>
<DropIndicator classNames="my-1" isVisible={instruction === "reorder-above"} />
{children(isDragging, isMakeChild, dragHandleRef)}
{isLastChild && <DropIndicator classNames="my-1" isVisible={instruction === "reorder-below"} />}
</div>
);
});

View File

@@ -0,0 +1,67 @@
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import type { IIssueLabel, IPragmaticPayloadLocation, InstructionType, 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 label 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 label,
// 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 label can be dropped onto the droptarget
* @param source
* @param label
* @param isCurrentChild if the dropTarget is a child
* @returns
*/
export const getCanDrop = (source: TDropTarget, label: IIssueLabel | undefined, isCurrentChild: boolean) => {
const sourceData = source?.data;
if (!sourceData) return false;
// a label cannot be dropped on to itself and it's parent cannon be dropped on the child
if (sourceData.id === label?.id || sourceData.id === label?.parent) return false;
// if current dropTarget is a child and the label being dropped is a group then don't enable drop
if (isCurrentChild && sourceData.isGroup) return false;
return true;
};

View File

@@ -0,0 +1,170 @@
import type { Dispatch, SetStateAction } from "react";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// store
// icons
// types
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import type { IIssueLabel } from "@plane/types";
// components
import { captureClick } from "@/helpers/event-tracker.helper";
import type { TLabelOperationsCallbacks } from "./create-update-label-inline";
import { CreateUpdateLabelInline } from "./create-update-label-inline";
import type { ICustomMenuItem } from "./label-block/label-item-block";
import { LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC";
import { ProjectSettingLabelItem } from "./project-setting-label-item";
type Props = {
label: IIssueLabel;
labelChildren: IIssueLabel[];
handleLabelDelete: (label: IIssueLabel) => void;
isUpdating: boolean;
setIsUpdating: Dispatch<SetStateAction<boolean>>;
isLastChild: boolean;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
labelOperationsCallbacks: TLabelOperationsCallbacks;
isEditable?: boolean;
};
export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
const {
label,
labelChildren,
handleLabelDelete,
isUpdating,
setIsUpdating,
isLastChild,
onDrop,
isEditable = false,
labelOperationsCallbacks,
} = props;
// states
const [isEditLabelForm, setEditLabelForm] = useState(false);
const customMenuItems: ICustomMenuItem[] = [
{
CustomIcon: Pencil,
onClick: () => {
captureClick({
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_CONTEXT_MENU,
});
setEditLabelForm(true);
setIsUpdating(true);
},
isVisible: true,
text: "Edit label",
key: "edit_label",
},
{
CustomIcon: Trash2,
onClick: () => {
captureClick({
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_CONTEXT_MENU,
});
handleLabelDelete(label);
},
isVisible: true,
text: "Delete label",
key: "delete_label",
},
];
return (
<LabelDndHOC label={label} isGroup isChild={false} isLastChild={isLastChild} onDrop={onDrop}>
{(isDragging, isDroppingInLabel, dragHandleRef) => (
<div
className={`rounded ${isDroppingInLabel ? "border-[2px] border-custom-primary-100" : "border-[1.5px] border-transparent"}`}
>
<Disclosure
as="div"
className={`rounded text-custom-text-100 ${
!isDroppingInLabel ? "border-[0.5px] border-custom-border-200" : ""
} ${isDragging ? "bg-custom-background-80" : "bg-custom-background-100"}`}
defaultOpen
>
{({ open }) => (
<>
<div className={`py-3 pl-1 pr-3 ${!isUpdating && "max-h-full overflow-y-hidden"}`}>
<>
<div className="relative flex cursor-pointer items-center justify-between gap-2">
{isEditLabelForm ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
labelOperationsCallbacks={labelOperationsCallbacks}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
) : (
<LabelItemBlock
label={label}
isDragging={isDragging}
customMenuItems={customMenuItems}
handleLabelDelete={handleLabelDelete}
isLabelGroup
dragHandleRef={dragHandleRef}
/>
)}
<Disclosure.Button>
<span>
<ChevronDown
className={`h-4 w-4 text-custom-sidebar-text-400 ${!open ? "rotate-90 transform" : ""}`}
/>
</span>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="ml-6">
{labelChildren.map((child, index) => (
<div key={child.id} className={`group flex w-full items-center text-sm`}>
<div className="w-full">
<ProjectSettingLabelItem
label={child}
handleLabelDelete={() => handleLabelDelete(child)}
setIsUpdating={setIsUpdating}
isParentDragging={isDragging}
isChild
isLastChild={index === labelChildren.length - 1}
onDrop={onDrop}
isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/>
</div>
</div>
))}
</div>
</Disclosure.Panel>
</Transition>
</>
</div>
</>
)}
</Disclosure>
</div>
)}
</LabelDndHOC>
);
});

View File

@@ -0,0 +1,123 @@
import type { Dispatch, SetStateAction } from "react";
import React, { useState } from "react";
import { useParams } from "next/navigation";
import { X, Pencil } from "lucide-react";
// types
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import type { IIssueLabel } from "@plane/types";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useLabel } from "@/hooks/store/use-label";
// components
import type { TLabelOperationsCallbacks } from "./create-update-label-inline";
import { CreateUpdateLabelInline } from "./create-update-label-inline";
import type { ICustomMenuItem } from "./label-block/label-item-block";
import { LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC";
type Props = {
label: IIssueLabel;
handleLabelDelete: (label: IIssueLabel) => void;
setIsUpdating: Dispatch<SetStateAction<boolean>>;
isParentDragging?: boolean;
isChild: boolean;
isLastChild: boolean;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
labelOperationsCallbacks: TLabelOperationsCallbacks;
isEditable?: boolean;
};
export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
const {
label,
setIsUpdating,
handleLabelDelete,
isChild,
isLastChild,
isParentDragging = false,
onDrop,
labelOperationsCallbacks,
isEditable = false,
} = props;
// states
const [isEditLabelForm, setEditLabelForm] = useState(false);
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { updateLabel } = useLabel();
const removeFromGroup = (label: IIssueLabel) => {
if (!workspaceSlug || !projectId) return;
updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, {
parent: null,
});
};
const customMenuItems: ICustomMenuItem[] = [
{
CustomIcon: X,
onClick: removeFromGroup,
isVisible: !!label.parent,
text: "Remove from group",
key: "remove_from_group",
},
{
CustomIcon: Pencil,
onClick: () => {
setEditLabelForm(true);
setIsUpdating(true);
captureClick({
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_CONTEXT_MENU,
});
},
isVisible: true,
text: "Edit label",
key: "edit_label",
},
];
return (
<LabelDndHOC label={label} isGroup={false} isChild={isChild} isLastChild={isLastChild} onDrop={onDrop}>
{(isDragging, isDroppingInLabel, dragHandleRef) => (
<div
className={`rounded ${isDroppingInLabel ? "border-[2px] border-custom-primary-100" : "border-[1.5px] border-transparent"}`}
>
<div
className={`py-3 px-1 group relative flex items-center justify-between gap-2 space-y-3 rounded ${
isDroppingInLabel ? "" : "border-[0.5px] border-custom-border-200"
} ${isDragging || isParentDragging ? "bg-custom-background-80" : "bg-custom-background-100"}`}
>
{isEditLabelForm ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
labelOperationsCallbacks={labelOperationsCallbacks}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
) : (
<LabelItemBlock
label={label}
isDragging={isDragging}
customMenuItems={customMenuItems}
handleLabelDelete={handleLabelDelete}
dragHandleRef={dragHandleRef}
disabled={!isEditable}
/>
)}
</div>
</div>
)}
</LabelDndHOC>
);
};

View File

@@ -0,0 +1,182 @@
"use client";
import React, { useState, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types";
import { Loader } from "@plane/ui";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import type { TLabelOperationsCallbacks } from "@/components/labels";
import {
CreateUpdateLabelInline,
DeleteLabelModal,
ProjectSettingLabelGroup,
ProjectSettingLabelItem,
} from "@/components/labels";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useLabel } from "@/hooks/store/use-label";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// local imports
import { SettingsHeading } from "../settings/heading";
export const ProjectSettingsLabelList: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
// refs
const scrollToRef = useRef<HTMLDivElement>(null);
// states
const [showLabelForm, setLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [selectDeleteLabel, setSelectDeleteLabel] = useState<IIssueLabel | null>(null);
// plane hooks
const { t } = useTranslation();
// store hooks
const { projectLabels, updateLabelPosition, projectLabelsTree, createLabel, updateLabel } = useLabel();
const { allowPermissions } = useUserPermissions();
// derived values
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/project-settings/labels" });
const labelOperationsCallbacks: TLabelOperationsCallbacks = {
createLabel: (data: Partial<IIssueLabel>) => createLabel(workspaceSlug?.toString(), projectId?.toString(), data),
updateLabel: (labelId: string, data: Partial<IIssueLabel>) =>
updateLabel(workspaceSlug?.toString(), projectId?.toString(), labelId, data),
};
const newLabel = () => {
setIsUpdating(false);
setLabelForm(true);
};
const onDrop = (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => {
if (workspaceSlug && projectId) {
updateLabelPosition(
workspaceSlug?.toString(),
projectId?.toString(),
draggingLabelId,
droppedParentId,
droppedLabelId,
dropAtEndOfList
);
return;
}
};
return (
<>
<DeleteLabelModal
isOpen={!!selectDeleteLabel}
data={selectDeleteLabel ?? null}
onClose={() => setSelectDeleteLabel(null)}
/>
<SettingsHeading
title={t("project_settings.labels.heading")}
description={t("project_settings.labels.description")}
button={{
label: t("common.add_label"),
onClick: () => {
newLabel();
captureClick({
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_HEADER_CREATE_BUTTON,
});
},
}}
showButton={isEditable}
/>
<div className="w-full py-2">
{showLabelForm && (
<div className="my-2 w-full rounded border border-custom-border-200 px-3.5 py-2">
<CreateUpdateLabelInline
labelForm={showLabelForm}
setLabelForm={setLabelForm}
isUpdating={isUpdating}
labelOperationsCallbacks={labelOperationsCallbacks}
ref={scrollToRef}
onClose={() => {
setLabelForm(false);
setIsUpdating(false);
}}
/>
</div>
)}
{projectLabels ? (
projectLabels.length === 0 && !showLabelForm ? (
<div className="flex items-center justify-center h-full w-full">
<DetailedEmptyState
title={""}
description={""}
primaryButton={{
text: "Create your first label",
onClick: () => {
newLabel();
captureClick({
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.LABELS_EMPTY_STATE_CREATE_BUTTON,
});
},
}}
assetPath={resolvedPath}
className="w-full !px-0 !py-0"
size="md"
/>
</div>
) : (
projectLabelsTree && (
<div className="mt-3">
{projectLabelsTree.map((label, index) => {
if (label.children && label.children.length) {
return (
<ProjectSettingLabelGroup
key={label.id}
label={label}
labelChildren={label.children || []}
handleLabelDelete={(label: IIssueLabel) => setSelectDeleteLabel(label)}
isUpdating={isUpdating}
setIsUpdating={setIsUpdating}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/>
);
}
return (
<ProjectSettingLabelItem
label={label}
key={label.id}
setIsUpdating={setIsUpdating}
handleLabelDelete={(label) => setSelectDeleteLabel(label)}
isChild={false}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/>
);
})}
</div>
)
)
) : (
!showLabelForm && (
<Loader className="space-y-5">
<Loader.Item height="42px" />
<Loader.Item height="42px" />
<Loader.Item height="42px" />
<Loader.Item height="42px" />
</Loader>
)
)}
</div>
</>
);
});