Skip to content

Commit

Permalink
fix: split labels in kanban board
Browse files Browse the repository at this point in the history
  • Loading branch information
mathalav55 committed Dec 21, 2024
1 parent 9b71a70 commit 52b2e0a
Show file tree
Hide file tree
Showing 6 changed files with 421 additions and 295 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
);

const handleState = (stateId: string) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
Expand All @@ -111,7 +111,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
};

const handlePriority = (value: TIssuePriorities) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { priority: value }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
Expand All @@ -126,7 +126,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
};

const handleLabel = (ids: string[]) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
Expand All @@ -141,7 +141,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
};

const handleAssignee = (ids: string[]) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
Expand Down Expand Up @@ -195,7 +195,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
);

const handleStartDate = (date: Date | null) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
Expand All @@ -212,7 +212,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
};

const handleTargetDate = (date: Date | null) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
Expand All @@ -229,7 +229,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
};

const handleEstimate = (value: string | undefined) => {
updateIssue &&
if (updateIssue)
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
Expand Down Expand Up @@ -306,17 +306,15 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {

{/* label */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
<div className="h-5" onClick={handleEventPropagation}>
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || null}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
disabled={isReadOnly}
renderByDefault={isMobile}
hideDropdownArrow
/>
</div>
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || null}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
disabled={isReadOnly}
renderByDefault={isMobile}
hideDropdownArrow
/>
</WithDisplayPropertiesHOC>

{/* start date */}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./labels";
export * from "./all-properties";
export * from "./label-dropdown";
293 changes: 293 additions & 0 deletions web/core/components/issues/issue-layouts/properties/label-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
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";
import { useOutsideClickDetector } from "@plane/hooks";
import { IIssueLabel } from "@plane/types";
import { ComboDropDown } from "@plane/ui";
import { getRandomLabelColor } from "@/constants/label";
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<boolean>(false);
const [query, setQuery] = useState<string>("");
const [submitting, setSubmitting] = useState<boolean>(false);

//refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);

// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(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 = projectLabels.map((label) => ({
value: label?.id,
query: label?.name,
content: (
<div className="flex items-center justify-start gap-2 overflow-hidden">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color,
}}
/>
<div className="line-clamp-1 inline-block truncate">{label?.name}</div>
</div>
),
}));

const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));

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<HTMLInputElement>) => {
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<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
},
[toggleDropdown]
);

useEffect(() => {
if (isOpen && inputRef.current && !isMobile) {
inputRef.current.focus();
}
}, [isOpen, isMobile]);

useOutsideClickDetector(dropdownRef, handleClose);

const comboButton = useMemo(
() => (
<button
ref={setReferenceElement}
type="button"
className={`clickable flex w-full h-full items-center justify-center gap-1 text-xs ${fullWidth && "hover:bg-custom-background-80"} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={handleOnClick}
disabled={disabled}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
),
[buttonClassName, disabled, fullWidth, handleOnClick, hideDropdownArrow, label, maxRender, value.length]
);

const preventPropagation = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
};

return (
<div className={`${fullHeight ? "h-full" : "h-5"}`} onClick={preventPropagation}>
<ComboDropDown
as="div"
ref={dropdownRef}
className={`w-auto max-w-full h-full flex-shrink-0 text-left ${className}`}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
button={comboButton}
renderByDefault={renderByDefault}
multiple
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={`z-10 my-1 w-48 h-auto whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
ref={inputRef}
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name || ""}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
{isLoading ? (
<p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
onKeyDown={(e) => {
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 && (
<div className="flex-shrink-0">
<Check className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))
) : submitting ? (
<Loader className="spin h-3.5 w-3.5" />
) : canCreateLabel ? (
<p
onClick={() => {
if (!query.length) return;
handleAddLabel(query);
}}
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
>
{query.length ? (
<>
+ Add <span className="text-custom-text-100">&quot;{query}&quot;</span> to labels
</>
) : (
"Type to add a new label"
)}
</p>
) : (
<p className="text-left text-custom-text-200 ">No matching results.</p>
)}
</div>
</div>
</Combobox.Options>
)}
</ComboDropDown>
</div>
);
};
Loading

0 comments on commit 52b2e0a

Please sign in to comment.