diff --git a/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/web/core/components/issues/issue-layouts/properties/all-properties.tsx index fd285daf10c..e27c4d1119d 100644 --- a/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -96,7 +96,7 @@ export const IssueProperties: React.FC = observer((props) => { ); const handleState = (stateId: string) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => { captureIssueEvent({ eventName: ISSUE_UPDATED, @@ -111,7 +111,7 @@ export const IssueProperties: React.FC = observer((props) => { }; const handlePriority = (value: TIssuePriorities) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { priority: value }).then(() => { captureIssueEvent({ eventName: ISSUE_UPDATED, @@ -126,7 +126,7 @@ export const IssueProperties: React.FC = observer((props) => { }; const handleLabel = (ids: string[]) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => { captureIssueEvent({ eventName: ISSUE_UPDATED, @@ -141,7 +141,7 @@ export const IssueProperties: React.FC = observer((props) => { }; const handleAssignee = (ids: string[]) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => { captureIssueEvent({ eventName: ISSUE_UPDATED, @@ -195,7 +195,7 @@ export const IssueProperties: React.FC = observer((props) => { ); const handleStartDate = (date: Date | null) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then( () => { captureIssueEvent({ @@ -212,7 +212,7 @@ export const IssueProperties: React.FC = observer((props) => { }; const handleTargetDate = (date: Date | null) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then( () => { captureIssueEvent({ @@ -229,7 +229,7 @@ export const IssueProperties: React.FC = observer((props) => { }; const handleEstimate = (value: string | undefined) => { - updateIssue && + if (updateIssue) updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => { captureIssueEvent({ eventName: ISSUE_UPDATED, @@ -304,21 +304,6 @@ export const IssueProperties: React.FC = observer((props) => { - {/* label */} - -
- -
-
- {/* start date */}
@@ -511,6 +496,20 @@ export const IssueProperties: React.FC = observer((props) => {
+ + {/* label */} + + + ); }); diff --git a/web/core/components/issues/issue-layouts/properties/index.ts b/web/core/components/issues/issue-layouts/properties/index.ts index 8bdc3ea3f7d..668979012ce 100644 --- a/web/core/components/issues/issue-layouts/properties/index.ts +++ b/web/core/components/issues/issue-layouts/properties/index.ts @@ -1,2 +1,3 @@ export * from "./labels"; export * from "./all-properties"; +export * from "./label-dropdown"; diff --git a/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx b/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx new file mode 100644 index 00000000000..2cfc6941911 --- /dev/null +++ b/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; +import { useParams } from "next/navigation"; +import { usePopper } from "react-popper"; +import { Check, ChevronDown, Loader, Search } from "lucide-react"; +import { Combobox } from "@headlessui/react"; +// plane helper +import { useOutsideClickDetector } from "@plane/hooks"; +// types +import { IIssueLabel } from "@plane/types"; +// components +import { ComboDropDown } from "@plane/ui"; +// constants +import { getRandomLabelColor } from "@/constants/label"; +// hooks +import { useLabel, useUserPermissions } from "@/hooks/store"; +import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants"; + +export interface ILabelDropdownProps { + projectId: string | null; + value: string[]; + onChange: (data: string[]) => void; + onClose?: () => void; + disabled?: boolean; + defaultOptions?: any; + hideDropdownArrow?: boolean; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + placement?: Placement; + maxRender?: number; + renderByDefault?: boolean; + fullWidth?: boolean; + fullHeight?: boolean; + label: React.ReactNode; +} + +export const LabelDropdown = (props: ILabelDropdownProps) => { + const { + projectId, + value, + onChange, + onClose, + disabled, + defaultOptions = [], + hideDropdownArrow = false, + className, + buttonClassName = "", + optionsClassName = "", + placement, + maxRender = 2, + renderByDefault = true, + fullWidth = false, + fullHeight = false, + label, + } = props; + + //router + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug?.toString(); + + //states + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [query, setQuery] = useState(""); + const [submitting, setSubmitting] = useState(false); + + //refs + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + //hooks + const { fetchProjectLabels, getProjectLabels, createLabel } = useLabel(); + const { isMobile } = usePlatformOS(); + const storeLabels = getProjectLabels(projectId); + const { allowPermissions } = useUserPermissions(); + + const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + let projectLabels: IIssueLabel[] = defaultOptions; + if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels; + + const options = useMemo( + () => + projectLabels.map((label) => ({ + value: label?.id, + query: label?.name, + content: ( +
+ +
{label?.name}
+
+ ), + })), + [projectLabels] + ); + + const filteredOptions = useMemo( + () => + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())), + [options, query] + ); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const onOpen = useCallback(() => { + if (!storeLabels && workspaceSlug && projectId) + fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + }, [storeLabels, workspaceSlug, projectId, fetchProjectLabels]); + + const toggleDropdown = useCallback(() => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen && onClose) onClose(); + }, [onOpen, onClose, isOpen]); + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + setQuery(""); + if (onClose) onClose(); + }; + + const handleAddLabel = async (labelName: string) => { + if (!projectId) return; + setSubmitting(true); + const label = await createLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() }); + onChange([...value, label.id]); + setQuery(""); + setSubmitting(false); + }; + + const searchInputKeyDown = async (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (query !== "" && e.key === "Escape") { + setQuery(""); + } + + if (query !== "" && e.key === "Enter") { + e.preventDefault(); + await handleAddLabel(query); + } + }; + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }, + [toggleDropdown] + ); + + useEffect(() => { + if (isOpen && inputRef.current && !isMobile) { + inputRef.current.focus(); + } + }, [isOpen, isMobile]); + + useOutsideClickDetector(dropdownRef, handleClose); + + const comboButton = useMemo( + () => ( + + ), + [buttonClassName, disabled, fullWidth, handleOnClick, hideDropdownArrow, label, maxRender, value.length] + ); + + const preventPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( +
+ + {isOpen && ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name || ""} + onKeyDown={searchInputKeyDown} + /> +
+
+ {isLoading ? ( +

Loading...

+ ) : filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + } + }} + className={({ active, selected }) => + `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && ( +
+ +
+ )} + + )} +
+ )) + ) : submitting ? ( + + ) : canCreateLabel ? ( +

{ + if (!query.length) return; + handleAddLabel(query); + }} + className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`} + > + {query.length ? ( + <> + + Add "{query}" to labels + + ) : ( + "Type to add a new label" + )} +

+ ) : ( +

No matching results.

+ )} +
+
+
+ )} +
+
+ ); +}; diff --git a/web/core/components/issues/issue-layouts/properties/labels.tsx b/web/core/components/issues/issue-layouts/properties/labels.tsx index 697bf376b71..25722abd6bc 100644 --- a/web/core/components/issues/issue-layouts/properties/labels.tsx +++ b/web/core/components/issues/issue-layouts/properties/labels.tsx @@ -1,30 +1,25 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Loader, Search, Tags } from "lucide-react"; -import { Combobox } from "@headlessui/react"; +import { Tags } from "lucide-react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types import { IIssueLabel } from "@plane/types"; // ui -import { ComboDropDown, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/ui"; // hooks -import { getRandomLabelColor } from "@/constants/label"; -import { useLabel, useUserPermissions } from "@/hooks/store"; -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; +import { cn } from "@plane/utils"; +import { useLabel } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; -// constants +import { LabelDropdown } from "./label-dropdown"; export interface IIssuePropertyLabels { projectId: string | null; value: string[]; - defaultOptions?: any; + defaultOptions?: unknown; onChange: (data: string[]) => void; disabled?: boolean; hideDropdownArrow?: boolean; @@ -38,6 +33,7 @@ export interface IIssuePropertyLabels { onClose?: () => void; renderByDefault?: boolean; fullWidth?: boolean; + fullHeight?: boolean; } export const IssuePropertyLabels: React.FC = observer((props) => { @@ -49,318 +45,172 @@ export const IssuePropertyLabels: React.FC = observer((pro onClose, disabled, hideDropdownArrow = false, - className, buttonClassName = "", - optionsClassName = "", placement, maxRender = 2, noLabelBorder = false, placeholderText, renderByDefault = true, fullWidth = false, + fullHeight = false, } = props; - // router - const { workspaceSlug: routerWorkspaceSlug } = useParams(); - const workspaceSlug = routerWorkspaceSlug?.toString(); // states - const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - const [isLoading, setIsLoading] = useState(false); // store hooks - const { fetchProjectLabels, getProjectLabels, createLabel } = useLabel(); + const { getProjectLabels } = useLabel(); const { isMobile } = usePlatformOS(); const storeLabels = getProjectLabels(projectId); - const { allowPermissions } = useUserPermissions(); - - const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - - const onOpen = () => { - if (!storeLabels && workspaceSlug && projectId) - fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); - }; const handleClose = () => { if (!isOpen) return; setIsOpen(false); - setQuery(""); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); + if (onClose) onClose(); }; useOutsideClickDetector(dropdownRef, handleClose); - const searchInputKeyDown = async (e: React.KeyboardEvent) => { - if (query !== "" && e.key === "Escape") { - e.stopPropagation(); - setQuery(""); - } - - if (query !== "" && e.key === "Enter") { - e.stopPropagation(); - e.preventDefault(); - await handleAddLabel(query); - } - }; - useEffect(() => { if (isOpen && inputRef.current && !isMobile) { inputRef.current.focus(); } }, [isOpen, isMobile]); - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - if (!value) return null; - let projectLabels: IIssueLabel[] = defaultOptions; + let projectLabels: IIssueLabel[] = defaultOptions as IIssueLabel[]; if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels; - const options = projectLabels.map((label) => ({ - value: label?.id, - query: label?.name, - content: ( -
- -
{label?.name}
-
+ const NoLabel = useMemo( + () => ( + +
+ + {placeholderText} +
+
), - })); - - const filteredOptions = - query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + [placeholderText, fullWidth, noLabelBorder, isMobile] + ); - const label = ( -
- {value.length > 0 ? ( - value.length <= maxRender ? ( - <> - {projectLabels - ?.filter((l) => value.includes(l?.id)) - .map((label) => ( - -
-
- -
{label?.name}
-
-
-
- ))} - - ) : ( -
- value.includes(l?.id)) - .map((l) => l?.name) - .join(", ")} - renderByDefault={false} - > -
- - {`${value.length} Labels`} -
-
-
- ) - ) : ( + const LabelSummary = useMemo( + () => ( +
value.includes(l?.id)) + .map((l) => l?.name) + .join(", ")} renderByDefault={false} > -
- - {placeholderText} +
+ + {`${value.length} Labels`}
- )} -
+
+ ), + [fullWidth, disabled, noLabelBorder, isMobile, projectLabels, value] ); - const comboButton = ( - + const LabelItem = useCallback( + ({ label }: { label: IIssueLabel }) => ( + +
+
+ +
{label?.name}
+
+
+
+ ), + [disabled, fullWidth, isMobile, noLabelBorder, renderByDefault] ); - const handleAddLabel = async (labelName: string) => { - if (!projectId) return; - setSubmitting(true); - const label = await createLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() }); - onChange([...value, label.id]); - setQuery(""); - setSubmitting(false); - }; - return ( - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name || ""} - onKeyDown={searchInputKeyDown} + <> + {value.length > 0 ? ( + value.length <= maxRender ? ( + projectLabels + ?.filter((l) => value.includes(l?.id)) + .map((label) => ( + } /> -
-
- {isLoading ? ( -

Loading...

- ) : filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - } - }} - className={({ active, selected }) => - `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && ( -
- -
- )} - - )} -
- )) - ) : submitting ? ( - - ) : canCreateLabel ? ( -

{ - if (!query.length) return; - handleAddLabel(query); - }} - className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`} - > - {query.length ? ( - <> - + Add "{query}" to labels - - ) : ( - "Type to add a new label" - )} -

- ) : ( -

No matching results.

- )} -
-
-
+ )) + ) : ( + + ) + ) : ( + )} -
+ ); }); diff --git a/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index 5afa6cc5539..49de4064a99 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -37,6 +37,7 @@ export const SpreadsheetLabelColumn: React.FC = observer((props: Props) = onClose={onClose} noLabelBorder fullWidth + fullHeight />
); diff --git a/web/core/components/issues/workspace-draft/draft-issue-properties.tsx b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx index 156e3c730cb..92f61f8e0f4 100644 --- a/web/core/components/issues/workspace-draft/draft-issue-properties.tsx +++ b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx @@ -160,16 +160,14 @@ export const DraftIssueProperties: React.FC = observer((props) {/* label */} -
- -
+ {/* start date */}