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

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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