From e653988ec7f939622da49516051c48a742e06e13 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sun, 23 Aug 2026 12:48:45 +0100 Subject: [PATCH] feat: per-group label pulldowns and named label chips Work item creation now renders one dropdown per label group (a top-level label that has children), plus a final "Labels" dropdown for ungrouped labels. Each dropdown owns only its own label ids, so a selection in one group never clobbers another. Labels created from inside a group dropdown are created as children of that group. Displays that previously collapsed to "N Labels" now render the actual label chips (name + colour dot) with a "+N" overflow chip; the full list stays in the tooltip. - select/base.tsx: add `placeholder` and `flat` props, extract LabelOption - select/dropdown.tsx: build group/ungrouped scopes, fetch labels on mount - ui/labels-list.tsx, issues/label.tsx, issue-layouts/properties/labels.tsx, space properties/labels.tsx: render names instead of a count Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T68HuqHfZwiS1BuA4injJN --- .../issue-layouts/properties/labels.tsx | 50 ++++--- .../issue-layouts/properties/labels.tsx | 78 +++++++---- apps/web/core/components/issues/label.tsx | 74 +++++----- .../core/components/issues/select/base.tsx | 129 +++++++++--------- .../components/issues/select/dropdown.tsx | 74 ++++++++-- apps/web/core/components/ui/labels-list.tsx | 48 ++++--- 6 files changed, 272 insertions(+), 181 deletions(-) diff --git a/apps/space/components/issues/issue-layouts/properties/labels.tsx b/apps/space/components/issues/issue-layouts/properties/labels.tsx index 4a6d1ab0a03d..0ab1a5c06379 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) => ( - -
-
- -
{label?.name}
-
-
-
- ))} - - ) : ( + {shownLabels.map((label) => ( + +
+
+ +
{label?.name}
+
+
+
+ ))} + {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 816e42837364..8f08c3b598c3 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 bc9d040f861f..907830910bab 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 6503d92a8492..398793cd57ea 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 b21bb267da78..0d6a36d68bbe 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 c3261b246661..e9bbd69fde91 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}`} +
+ )} +
+
); }