feat: init
This commit is contained in:
255
apps/web/core/components/labels/create-update-label-inline.tsx
Normal file
255
apps/web/core/components/labels/create-update-label-inline.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
88
apps/web/core/components/labels/delete-label-modal.tsx
Normal file
88
apps/web/core/components/labels/delete-label-modal.tsx
Normal 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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
5
apps/web/core/components/labels/index.ts
Normal file
5
apps/web/core/components/labels/index.ts
Normal 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";
|
||||
109
apps/web/core/components/labels/label-block/label-item-block.tsx
Normal file
109
apps/web/core/components/labels/label-block/label-item-block.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
apps/web/core/components/labels/label-block/label-name.tsx
Normal file
27
apps/web/core/components/labels/label-block/label-name.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
170
apps/web/core/components/labels/label-drag-n-drop-HOC.tsx
Normal file
170
apps/web/core/components/labels/label-drag-n-drop-HOC.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
67
apps/web/core/components/labels/label-utils.ts
Normal file
67
apps/web/core/components/labels/label-utils.ts
Normal 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;
|
||||
};
|
||||
170
apps/web/core/components/labels/project-setting-label-group.tsx
Normal file
170
apps/web/core/components/labels/project-setting-label-group.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
123
apps/web/core/components/labels/project-setting-label-item.tsx
Normal file
123
apps/web/core/components/labels/project-setting-label-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
182
apps/web/core/components/labels/project-setting-label-list.tsx
Normal file
182
apps/web/core/components/labels/project-setting-label-list.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user