diff --git a/apps/space/components/issues/issue-layouts/properties/labels.tsx b/apps/space/components/issues/issue-layouts/properties/labels.tsx
index 4a6d1ab0a03..0ab1a5c0637 100644
--- a/apps/space/components/issues/issue-layouts/properties/labels.tsx
+++ b/apps/space/components/issues/issue-layouts/properties/labels.tsx
@@ -35,38 +35,34 @@ export const IssueBlockLabels = observer(function IssueBlockLabels({ labelIds, s
);
+ const shownLabels = labels.slice(0, 2);
+ const hiddenCount = labels.length - shownLabels.length;
+
return (
- {labels.length <= 2 ? (
- <>
- {labels.map((label) => (
-
-
-
- ))}
- >
- ) : (
+ {shownLabels.map((label) => (
+
+
+
+ ))}
+ {hiddenCount > 0 && (
-
-
- {`${labels.length} Labels`}
-
+ {`+${hiddenCount}`}
)}
diff --git a/apps/web/core/components/issues/issue-layouts/properties/labels.tsx b/apps/web/core/components/issues/issue-layouts/properties/labels.tsx
index 816e4283736..8f08c3b598c 100644
--- a/apps/web/core/components/issues/issue-layouts/properties/labels.tsx
+++ b/apps/web/core/components/issues/issue-layouts/properties/labels.tsx
@@ -81,35 +81,66 @@ type LabelSummaryProps = {
disabled?: boolean;
projectLabels: IIssueLabel[];
value: string[];
+ maxRender: number;
};
-function LabelSummary({ isMobile, fullWidth, noLabelBorder, disabled, projectLabels, value }: LabelSummaryProps) {
+function LabelSummary({
+ isMobile,
+ fullWidth,
+ noLabelBorder,
+ disabled,
+ projectLabels,
+ value,
+ maxRender,
+}: LabelSummaryProps) {
const { t } = useTranslation();
+ // labels the work item actually has, in project order
+ const selectedLabels = projectLabels?.filter((l) => value.includes(l?.id)) ?? [];
+ const shownLabels = selectedLabels.slice(0, maxRender);
+ const hiddenCount = value.length - shownLabels.length;
+
return (
-
l?.name).join(", ")}
+ renderByDefault={false}
>
-
value.includes(l?.id))
- .map((l) => l?.name)
- .join(", ")}
- renderByDefault={false}
+
-
-
- {`${value.length} Labels`}
-
-
-
+ {shownLabels.map((label) => (
+
+
+ {label?.name}
+
+ ))}
+ {hiddenCount > 0 && (
+
+ {`+${hiddenCount}`}
+
+ )}
+
+
);
}
@@ -254,6 +285,7 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
disabled={disabled}
projectLabels={projectLabels}
value={value}
+ maxRender={maxRender}
/>
}
/>
diff --git a/apps/web/core/components/issues/label.tsx b/apps/web/core/components/issues/label.tsx
index bc9d040f861..907830910ba 100644
--- a/apps/web/core/components/issues/label.tsx
+++ b/apps/web/core/components/issues/label.tsx
@@ -15,47 +15,43 @@ type Props = {
export function ViewIssueLabel({ labelDetails, maxRender = 1 }: Props) {
const { isMobile } = usePlatformOS();
+
+ if (!labelDetails || labelDetails.length === 0) return null;
+
+ const shownLabels = labelDetails.slice(0, maxRender);
+ const hiddenCount = labelDetails.length - shownLabels.length;
+
return (
<>
- {labelDetails?.length > 0 ? (
- labelDetails.length <= maxRender ? (
- <>
- {labelDetails.map((label) => (
-
-
-
-
- {label.name}
-
-
-
- ))}
- >
- ) : (
-
-
l.name).join(", ")}
- isMobile={isMobile}
- >
-
-
- {`${labelDetails.length} Labels`}
-
-
-
- )
- ) : (
- ""
+ {shownLabels.map((label) => (
+
+
+
+
+ {label.name}
+
+
+
+ ))}
+ {hiddenCount > 0 && (
+
+
l.name).join(", ")}
+ isMobile={isMobile}
+ >
+ {`+${hiddenCount}`}
+
+
)}
>
);
diff --git a/apps/web/core/components/issues/select/base.tsx b/apps/web/core/components/issues/select/base.tsx
index 6503d92a849..398793cd57e 100644
--- a/apps/web/core/components/issues/select/base.tsx
+++ b/apps/web/core/components/issues/select/base.tsx
@@ -1,3 +1,5 @@
+// oxlint-disable jsx_a11y/click-events-have-key-events
+// oxlint-disable jsx_a11y/no-static-element-interactions
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
@@ -37,8 +39,53 @@ export type TWorkItemLabelSelectBaseProps = {
createLabel?: (data: Partial
) => Promise;
tabIndex?: number;
value: string[];
+ /** Text shown on the button when nothing is selected. Defaults to "Labels". */
+ placeholder?: string;
+ /**
+ * When true the options are rendered as one flat list, with no group headers.
+ * Used by the per-group dropdowns, where every option already belongs to one group.
+ */
+ flat?: boolean;
};
+type TLabelOptionProps = {
+ label: IIssueLabel;
+ minWidth?: boolean;
+};
+
+function LabelOption({ label, minWidth = false }: TLabelOptionProps) {
+ return (
+
+ cn(
+ "group flex w-full cursor-pointer items-center gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none",
+ active && "bg-layer-1",
+ minWidth && "min-w-[14rem]"
+ )
+ }
+ value={label.id}
+ >
+ {({ selected }) => (
+
+
+
+ {label.name}
+
+
+
+
+
+ )}
+
+ );
+}
+
export const WorkItemLabelSelectBase = observer(function WorkItemLabelSelectBase(props: TWorkItemLabelSelectBaseProps) {
const {
buttonClassName,
@@ -54,6 +101,8 @@ export const WorkItemLabelSelectBase = observer(function WorkItemLabelSelectBase
createLabel,
tabIndex,
value,
+ placeholder,
+ flat = false,
} = props;
// refs
const dropdownRef = useRef(null);
@@ -73,7 +122,7 @@ export const WorkItemLabelSelectBase = observer(function WorkItemLabelSelectBase
placement: placement ?? "bottom-start",
});
// derived values
- const labelsList = labelIds.map((labelId) => getLabelById(labelId)).filter((label) => !!label);
+ const labelsList = labelIds.map((labelId) => getLabelById(labelId)).filter((l) => !!l);
const filteredOptions =
query === "" ? labelsList : labelsList?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
@@ -186,7 +235,7 @@ export const WorkItemLabelSelectBase = observer(function WorkItemLabelSelectBase
)}
>
- {t("labels")}
+ {placeholder ?? t("labels")}
)}
@@ -214,78 +263,30 @@ export const WorkItemLabelSelectBase = observer(function WorkItemLabelSelectBase
{labelsList && filteredOptions ? (
filteredOptions.length > 0 ? (
- filteredOptions.map((label) => {
- const children = labelsList?.filter((l) => l.parent === label.id);
+ flat ? (
+ filteredOptions.map((option) =>
)
+ ) : (
+ filteredOptions.map((option) => {
+ const children = labelsList?.filter((l) => l.parent === option.id);
- if (children.length === 0) {
- if (!label.parent)
- return (
-
- `${
- active ? "bg-layer-1" : ""
- } group flex w-full cursor-pointer items-center gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none`
- }
- value={label.id}
- >
- {({ selected }) => (
-
-
-
- {label.name}
-
-
-
-
-
- )}
-
- );
- } else
+ if (children.length === 0) {
+ if (!option.parent) return
;
+ return null;
+ }
return (
-
+
- {label.name}
+ {option.name}
{children.map((child) => (
-
- `${
- active ? "bg-layer-1" : ""
- } group flex min-w-[14rem] cursor-pointer items-center gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none`
- }
- value={child.id}
- >
- {({ selected }) => (
-
-
-
- {child.name}
-
-
-
-
-
- )}
-
+
))}
);
- })
+ })
+ )
) : submitting ? (
) : createLabelEnabled ? (
diff --git a/apps/web/core/components/issues/select/dropdown.tsx b/apps/web/core/components/issues/select/dropdown.tsx
index b21bb267da7..0d6a36d68bb 100644
--- a/apps/web/core/components/issues/select/dropdown.tsx
+++ b/apps/web/core/components/issues/select/dropdown.tsx
@@ -4,10 +4,11 @@
* See the LICENSE file for details.
*/
-import React from "react";
+import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
+import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types";
import { EUserPermissions } from "@plane/types";
// hooks
@@ -22,9 +23,11 @@ type TWorkItemLabelSelectProps = Omit
{
+ if (projectLabelIds === undefined && workspaceSlug && projectId)
+ fetchProjectLabels(workspaceSlug.toString(), projectId);
+ }, [projectLabelIds, workspaceSlug, projectId, fetchProjectLabels]);
+
const handleCreateLabel = (data: Partial) => {
if (!workspaceSlug || !projectId) {
throw new Error("Workspace slug or project ID is missing");
@@ -47,14 +57,58 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: TWorkI
return createLabel(workspaceSlug.toString(), projectId, data);
};
+ // A "group" is a label that has children. Its children are the selectable options.
+ // Everything else with no parent is a plain, ungrouped label.
+ const labels = (projectLabelIds ?? []).map((id) => getLabelById(id)).filter((l): l is IIssueLabel => !!l);
+ const groups = labels
+ .filter((l) => !l.parent && labels.some((child) => child.parent === l.id))
+ .map((group) => ({
+ group,
+ childIds: labels.filter((child) => child.parent === group.id).map((child) => child.id),
+ }));
+ const groupIds = new Set(groups.map((g) => g.group.id));
+ const ungroupedLabelIds = labels.filter((l) => !l.parent && !groupIds.has(l.id)).map((l) => l.id);
+
+ // Replace only the ids owned by this dropdown, leave every other selected label untouched.
+ const handleScopedChange = (scopeIds: string[]) => (nextScopeValue: string[]) => {
+ const scope = new Set(scopeIds);
+ onChange([...(value ?? []).filter((id) => !scope.has(id)), ...nextScopeValue]);
+ };
+
+ const scopedValue = (scopeIds: string[]) => {
+ const scope = new Set(scopeIds);
+ return (value ?? []).filter((id) => scope.has(id));
+ };
+
return (
-
+
+ {groups.map(({ group, childIds }) => (
+ handleCreateLabel({ ...data, parent: group.id })}
+ createLabelEnabled={!!canCreateLabel}
+ />
+ ))}
+
+
);
});
diff --git a/apps/web/core/components/ui/labels-list.tsx b/apps/web/core/components/ui/labels-list.tsx
index c3261b24666..e9bbd69fde9 100644
--- a/apps/web/core/components/ui/labels-list.tsx
+++ b/apps/web/core/components/ui/labels-list.tsx
@@ -18,26 +18,38 @@ type IssueLabelsListProps = {
};
export function IssueLabelsList(props: IssueLabelsListProps) {
- const { labels } = props;
+ const { labels, length = 3, showLength = true } = props;
const { isMobile } = usePlatformOS();
+
+ const visibleLabels = labels?.filter((l) => !!l) ?? [];
+ if (visibleLabels.length === 0) return null;
+
+ const shownLabels = visibleLabels.slice(0, length);
+ const hiddenCount = visibleLabels.length - shownLabels.length;
+
return (
- <>
- {labels && (
- <>
- l?.name).join(", ")}
- isMobile={isMobile}
+ l.name).join(", ")}
+ isMobile={isMobile}
+ >
+
+ {shownLabels.map((l) => (
+
-
-
- {labels.length}
- Labels
-
-
- >
- )}
- >
+
+
{l.name}
+
+ ))}
+ {showLength && hiddenCount > 0 && (
+
+ {`+${hiddenCount}`}
+
+ )}
+
+
);
}