snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
259
apps/web/core/components/labels/create-update-label-inline.tsx
Normal file
259
apps/web/core/components/labels/create-update-label-inline.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } 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";
|
||||
|
||||
// error codes
|
||||
const errorCodes = {
|
||||
LABEL_NAME_ALREADY_EXISTS: "LABEL_NAME_ALREADY_EXISTS",
|
||||
};
|
||||
|
||||
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: "var(--text-color-secondary)",
|
||||
};
|
||||
|
||||
export const CreateUpdateLabelInline = observer(
|
||||
forwardRef(function CreateUpdateLabelInline(
|
||||
props: TCreateUpdateLabelInlineProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
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();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getErrorMessage = (error: any, operation: "create" | "update"): string => {
|
||||
const errorData = error ?? {};
|
||||
|
||||
const labelError = errorData.name?.includes(errorCodes.LABEL_NAME_ALREADY_EXISTS);
|
||||
if (labelError) {
|
||||
return t("label.create.already_exists");
|
||||
}
|
||||
|
||||
// Fallback to general error messages
|
||||
if (operation === "create") {
|
||||
return errorData?.detail ?? errorData?.error ?? t("common.something_went_wrong");
|
||||
}
|
||||
|
||||
return errorData?.error ?? t("project_settings.labels.toast.error");
|
||||
};
|
||||
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabel> = async (formData) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
await labelOperationsCallbacks
|
||||
.createLabel(formData)
|
||||
.then((_res) => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = getErrorMessage(error, "create");
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: errorMessage,
|
||||
});
|
||||
reset(formData);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabel> = async (formData) => {
|
||||
if (!labelToUpdate?.id || isSubmitting) return;
|
||||
|
||||
await labelOperationsCallbacks
|
||||
.updateLabel(labelToUpdate.id, formData)
|
||||
.then((_res) => {
|
||||
reset(defaultValues);
|
||||
handleClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = getErrorMessage(error, "update");
|
||||
setToast({
|
||||
title: "Oops!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: errorMessage,
|
||||
});
|
||||
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-surface-1 ${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-14 font-medium focus:outline-none ${
|
||||
open ? "text-primary" : "text-secondary"
|
||||
}`}
|
||||
>
|
||||
<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 top-full left-0 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 } }) => (
|
||||
<Field name="name" invalid={Boolean(errors.name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="labelName"
|
||||
name="name"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={t("project_settings.labels.label_title")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => handleClose()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(handleFormSubmit)();
|
||||
}}
|
||||
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-13 text-danger-primary">{errors.name?.message}</p>}
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
73
apps/web/core/components/labels/delete-label-modal.tsx
Normal file
73
apps/web/core/components/labels/delete-label-modal.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: IIssueLabel | null;
|
||||
};
|
||||
|
||||
export const DeleteLabelModal = observer(function DeleteLabelModal(props: 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(() => {
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsDeleteLoading(false);
|
||||
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-primary">{data?.name}</span>? This will
|
||||
remove the label from all the work item and from any views where the label is being filtered upon.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
11
apps/web/core/components/labels/index.ts
Normal file
11
apps/web/core/components/labels/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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";
|
||||
112
apps/web/core/components/labels/label-block/label-item-block.tsx
Normal file
112
apps/web/core/components/labels/label-block/label-item-block.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
// 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 | React.FC<ISvgIcons>;
|
||||
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 function 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-sm hover:bg-layer-1"
|
||||
onClick={() => {
|
||||
handleLabelDelete(label);
|
||||
}}
|
||||
>
|
||||
<CloseOutline className="size-3.5 flex-shrink-0 text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/web/core/components/labels/label-block/label-name.tsx
Normal file
33
apps/web/core/components/labels/label-block/label-name.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { GroupOutline } from "@makeplane/propel/icons";
|
||||
|
||||
interface ILabelName {
|
||||
name: string;
|
||||
color: string;
|
||||
isGroup: boolean;
|
||||
}
|
||||
|
||||
export function LabelName(props: ILabelName) {
|
||||
const { name, color, isGroup } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 pr-20">
|
||||
{isGroup ? (
|
||||
<GroupOutline 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-13">{name}</h6>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
apps/web/core/components/labels/label-drag-n-drop-HOC.tsx
Normal file
174
apps/web/core/components/labels/label-drag-n-drop-HOC.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 function LabelDragPreview(props: LabelDragPreviewProps) {
|
||||
const { label, isGroup } = props;
|
||||
|
||||
return (
|
||||
<div className="border-[1px] border-subtle bg-surface-1 py-3 pr-4 pl-2">
|
||||
<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(function LabelDndHOC(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, 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>
|
||||
);
|
||||
});
|
||||
73
apps/web/core/components/labels/label-utils.ts
Normal file
73
apps/web/core/components/labels/label-utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
167
apps/web/core/components/labels/project-setting-label-group.tsx
Normal file
167
apps/web/core/components/labels/project-setting-label-group.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { ChevronDownOutline, DeleteOutline, EditOutline } from "@makeplane/propel/icons";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// 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";
|
||||
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 = observer(function ProjectSettingLabelGroup(props: Props) {
|
||||
const {
|
||||
label,
|
||||
labelChildren,
|
||||
handleLabelDelete,
|
||||
isUpdating,
|
||||
setIsUpdating,
|
||||
isLastChild,
|
||||
onDrop,
|
||||
isEditable = false,
|
||||
labelOperationsCallbacks,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isEditLabelForm, setEditLabelForm] = useState(false);
|
||||
|
||||
const customMenuItems: ICustomMenuItem[] = [
|
||||
{
|
||||
CustomIcon: EditOutline,
|
||||
onClick: () => {
|
||||
setEditLabelForm(true);
|
||||
setIsUpdating(true);
|
||||
},
|
||||
isVisible: true,
|
||||
text: "Edit label",
|
||||
key: "edit_label",
|
||||
},
|
||||
{
|
||||
CustomIcon: DeleteOutline,
|
||||
onClick: () => {
|
||||
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-sm ${isDroppingInLabel ? "border-[2px] border-accent-strong" : "border-[1.5px] border-transparent"}`}
|
||||
>
|
||||
<Disclosure
|
||||
as="div"
|
||||
className={`rounded-sm text-primary ${
|
||||
!isDroppingInLabel ? "border-[0.5px] border-subtle" : ""
|
||||
} ${isDragging ? "bg-layer-1" : "bg-surface-1"}`}
|
||||
defaultOpen
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className={`py-3 pr-3 pl-1 ${!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>
|
||||
<ChevronDownOutline
|
||||
className={`h-4 w-4 text-placeholder ${!open ? "rotate-90 transform" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as="div"
|
||||
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-13`}>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
124
apps/web/core/components/labels/project-setting-label-item.tsx
Normal file
124
apps/web/core/components/labels/project-setting-label-item.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CloseOutline, EditOutline } from "@makeplane/propel/icons";
|
||||
// types
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// hooks
|
||||
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 function ProjectSettingLabelItem(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: CloseOutline,
|
||||
onClick: removeFromGroup,
|
||||
isVisible: !!label.parent,
|
||||
text: "Remove from group",
|
||||
key: "remove_from_group",
|
||||
},
|
||||
{
|
||||
CustomIcon: EditOutline,
|
||||
onClick: () => {
|
||||
setEditLabelForm(true);
|
||||
setIsUpdating(true);
|
||||
},
|
||||
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-sm ${isDroppingInLabel ? "border-[2px] border-accent-strong" : "border-[1.5px] border-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className={`group relative flex items-center justify-between gap-2 space-y-3 rounded-sm px-1 py-3 ${
|
||||
isDroppingInLabel ? "" : "border-[0.5px] border-subtle"
|
||||
} ${isDragging || isParentDragging ? "bg-layer-1" : "bg-surface-1"}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
174
apps/web/core/components/labels/project-setting-label-list.tsx
Normal file
174
apps/web/core/components/labels/project-setting-label-list.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import type { TLabelOperationsCallbacks } from "@/components/labels";
|
||||
import {
|
||||
CreateUpdateLabelInline,
|
||||
DeleteLabelModal,
|
||||
ProjectSettingLabelGroup,
|
||||
ProjectSettingLabelItem,
|
||||
} from "@/components/labels";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { SettingsHeading } from "../settings/heading";
|
||||
|
||||
export const ProjectSettingsLabelList = observer(function ProjectSettingsLabelList() {
|
||||
// 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 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")}
|
||||
control={
|
||||
isEditable && (
|
||||
<Button variant="primary" size="lg" onClick={newLabel}>
|
||||
{t("common.add_label")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 w-full">
|
||||
{showLabelForm && (
|
||||
<div className="my-2 w-full rounded-sm border border-subtle 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 ? (
|
||||
<EmptyStateCompact
|
||||
assetKey="label"
|
||||
assetClassName="size-20"
|
||||
title={t("settings_empty_state.labels.title")}
|
||||
description={t("settings_empty_state.labels.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("settings_empty_state.labels.cta_primary"),
|
||||
onClick: () => {
|
||||
newLabel();
|
||||
},
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
) : (
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
!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