From 2aade08275ea4508a5cfd79d0ea95487df320954 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 17:40:15 +0400 Subject: [PATCH] feat(work-item): add sub-group-by (swimlanes) to project work items Add an optional secondary grouping to the project work-item layouts. From the Display panel, once a primary Group by is chosen, a new "Sub-group by" control offers every other dimension (it can't equal the primary; "None" turns it off). - List: primary groups become sections, each split into nested sub-group sections. - Board: sub-groups become horizontal swimlanes, one band of primary-group columns per sub-group. Implemented as a parallel buildSubGroupedIssues on top of the existing buildGroupedIssues (reusing each dimension's ordering + titles), so the flat GroupedIssuesResult that other layouts consume is unchanged. Drag-and-drop is disabled in swimlane mode to keep the cross-dimension drop unambiguous. The sub-group control is hidden in the module view, which doesn't render swimlanes. Closes #177 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/layout/ModuleDetailHeader.tsx | 6 +- .../ProjectIssuesDisplayPanel.tsx | 53 ++++++++++- .../work-item/layouts/IssueLayoutBoard.tsx | 60 +++++++++++- .../work-item/layouts/IssueLayoutList.tsx | 52 ++++++++++- apps/web/src/lib/issueListGroupAndSort.ts | 92 +++++++++++++++++++ apps/web/src/lib/projectIssuesDisplay.ts | 32 ++++++- apps/web/src/lib/projectIssuesEvents.ts | 1 + apps/web/src/pages/IssueListPage.tsx | 35 ++++++- 8 files changed, 319 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/layout/ModuleDetailHeader.tsx b/apps/web/src/components/layout/ModuleDetailHeader.tsx index f3fbe049..a9e8bd4c 100644 --- a/apps/web/src/components/layout/ModuleDetailHeader.tsx +++ b/apps/web/src/components/layout/ModuleDetailHeader.tsx @@ -665,7 +665,11 @@ export function ModuleDetailHeader({ } > - + ( ( ); -type SectionId = 'properties' | 'group' | 'order'; +type SectionId = 'properties' | 'group' | 'subgroup' | 'order'; /** Order matches the work-items Display reference. */ const GROUP_OPTIONS: { value: SavedViewGroupBy; label: string }[] = [ @@ -130,15 +133,28 @@ const displayPanelCheckboxClass = export interface ProjectIssuesDisplayPanelProps { display: ProjectIssuesDisplayState; setDisplay: React.Dispatch>; + /** Show the "Sub-group by" control. Off where the layout doesn't render it. */ + enableSubGroup?: boolean; } -export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssuesDisplayPanelProps) { +export function ProjectIssuesDisplayPanel({ + display, + setDisplay, + enableSubGroup = true, +}: ProjectIssuesDisplayPanelProps) { const [sections, setSections] = useState>({ properties: true, group: true, + subgroup: true, order: true, }); + // Sub-group options exclude the current primary group-by (a dimension can't + // sub-group by itself); "None" turns sub-grouping off. + const subGroupOptions = GROUP_OPTIONS.filter( + (opt) => opt.value === 'none' || opt.value !== display.groupBy, + ); + const toggleSection = (id: SectionId) => { setSections((s) => ({ ...s, [id]: !s[id] })); }; @@ -195,12 +211,41 @@ export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssues value={opt.value} label={opt.label} selected={display.groupBy === opt.value} - onSelect={(v) => setDisplay((p) => ({ ...p, groupBy: v }))} + onSelect={(v) => + setDisplay((p) => ({ + ...p, + groupBy: v, + subGroupBy: normalizeSubGroupBy(v, p.subGroupBy), + })) + } /> ))} + {enableSubGroup && display.groupBy !== 'none' && ( + +
+ {subGroupOptions.map((opt) => ( + + setDisplay((p) => ({ ...p, subGroupBy: normalizeSubGroupBy(p.groupBy, v) })) + } + /> + ))} +
+
+ )} + boolean; groupBy?: SavedViewGroupBy; showEmptyGroups?: boolean; @@ -59,6 +64,7 @@ export function IssueLayoutBoard({ now, projectsById, groupedIssues, + subGroupedIssues, hasCol: hasColProp, groupBy, showEmptyGroups = false, @@ -168,8 +174,12 @@ export function IssueLayoutBoard({ return { columns, orphans }; }, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById, showEmptyGroups]); + // Drag-and-drop is disabled in swimlane mode to keep the cross-dimension + // interaction unambiguous (a drop would otherwise be both a column and a lane). const dndEnabled = - Boolean(onCardMove) && (groupByStateGroup || !groupedIssues || groupBy === 'states'); + Boolean(onCardMove) && + !subGroupedIssues && + (groupByStateGroup || !groupedIssues || groupBy === 'states'); const renderCard = (issue: IssueApiResponse) => ( + {sg.subOrder.map((subKey) => { + const laneCount = sg.primaryOrder.reduce( + (n, pk) => n + (sg.cells.get(pk)?.get(subKey)?.length ?? 0), + 0, + ); + if (laneCount === 0 && !showEmptyGroups) return null; + return ( +
+

+ {sg.subTitle(subKey)} + {laneCount} +

+
+ {sg.primaryOrder.map((pk) => { + const items = sg.cells.get(pk)?.get(subKey) ?? []; + if (items.length === 0 && !showEmptyGroups) return null; + const color = stateById.get(pk)?.color ?? labelById.get(pk)?.color ?? undefined; + return ( + + {items.map(renderCard)} + {items.length === 0 && ( +

+ No work items +

+ )} +
+ ); + })} +
+
+ ); + })} + + ); + } + return (
{columns.map((col) => ( diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx index ed9f245f..bef3ec33 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx @@ -20,12 +20,17 @@ import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers' import { cn } from '../../../lib/utils'; import type { IssueApiResponse, LabelApiResponse } from '../../../api/types'; import type { Priority } from '../../../types'; -import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort'; +import type { + GroupedIssuesResult, + SubGroupedIssuesResult, +} from '../../../lib/issueListGroupAndSort'; import type { IssueLayoutProps } from './IssueLayoutTypes'; interface IssueLayoutListProps extends IssueLayoutProps { /** Pre-built grouping result from the parent (state/priority/cycle/etc. groupings). */ groupedIssues: GroupedIssuesResult; + /** Optional second-level grouping; when present, sections are nested. */ + subGroupedIssues?: SubGroupedIssuesResult | null; /** * Filter columns (display properties) — true means render. Accepts the same * narrow `SavedViewDisplayPropertyId` keys the parent's `hasCol` checks; we @@ -63,6 +68,7 @@ export function IssueLayoutList({ issueHref, now, groupedIssues, + subGroupedIssues, hasCol, showEmptyGroups, subWorkCountByParentId, @@ -335,6 +341,50 @@ export function IssueLayoutList({ ); } + // Nested (sub-grouped) rendering: each primary group holds sub-group sections. + if (subGroupedIssues) { + const sg = subGroupedIssues; + return ( +
+ {sg.primaryOrder.map((primaryKey) => { + const bySub = sg.cells.get(primaryKey); + const primaryCount = sg.subOrder.reduce( + (n, subKey) => n + (bySub?.get(subKey)?.length ?? 0), + 0, + ); + if (primaryCount === 0 && !showEmptyGroups) return null; + return ( +
+

+ {sg.primaryTitle(primaryKey)} + {primaryCount} +

+
+ {sg.subOrder.map((subKey) => { + const cellIssues = bySub?.get(subKey) ?? []; + if (cellIssues.length === 0 && !showEmptyGroups) return null; + return ( +
+

+ {sg.subTitle(subKey)} + + {cellIssues.length} + +

+
    + {cellIssues.map((issue) => renderRow(issue))} +
+
+ ); + })} +
+
+ ); + })} +
+ ); + } + return (
{groupedIssues.order.map((sectionKey) => { diff --git a/apps/web/src/lib/issueListGroupAndSort.ts b/apps/web/src/lib/issueListGroupAndSort.ts index 8339636e..7c97e8c2 100644 --- a/apps/web/src/lib/issueListGroupAndSort.ts +++ b/apps/web/src/lib/issueListGroupAndSort.ts @@ -403,3 +403,95 @@ export function buildGroupedIssues(params: { isFlat: true, }; } + +// subGroupKey returns the bucket key an issue falls into for a given dimension, +// mirroring the key derivation in buildGroupedIssues exactly (including the +// sentinel "none" keys) so a sub-grouping lines up with the same dimension's +// primary grouping. +export function subGroupKey( + dimension: SavedViewGroupBy, + issue: IssueApiResponse, + labels: LabelApiResponse[], +): string { + switch (dimension) { + case 'states': + return issue.state_id?.trim() ? issue.state_id : NONE_STATE_KEY; + case 'priority': + return issue.priority?.trim() || 'none'; + case 'cycle': + return issue.cycle_ids?.[0]?.trim() ?? NONE_CYCLE_KEY; + case 'module': + return issue.module_ids?.[0]?.trim() ?? NONE_MODULE_KEY; + case 'labels': { + const ids = [...(issue.label_ids ?? [])].sort((a, b) => { + const na = labels.find((l) => l.id === a)?.name ?? a; + const nb = labels.find((l) => l.id === b)?.name ?? b; + return na.localeCompare(nb); + }); + return ids[0] ?? NONE_LABEL_KEY; + } + case 'assignees': + return issue.assignee_ids?.[0]?.trim() ?? NONE_ASSIGNEE_KEY; + case 'created_by': + return issue.created_by_id?.trim() ?? NONE_CREATOR_KEY; + default: + return ALL_GROUP_KEY; + } +} + +// A two-level grouping: a primary group-by nested under (or crossed with) a +// secondary sub-group-by. Cells are keyed cells[primaryKey][subKey]. +export interface SubGroupedIssuesResult { + primaryOrder: string[]; + primaryTitle: (key: string) => string; + subOrder: string[]; + subTitle: (key: string) => string; + cells: Map>; +} + +// buildSubGroupedIssues layers a secondary dimension on top of the primary +// grouping. Returns null when sub-grouping doesn't apply (no primary group, no +// sub-group, or the two dimensions are equal), so callers fall back to the +// normal single-level grouping. It reuses buildGroupedIssues for both +// dimensions' order + titles so behavior stays consistent. +export function buildSubGroupedIssues(params: { + baseForGrouping: IssueApiResponse[]; + groupBy: SavedViewGroupBy; + subGroupBy: SavedViewGroupBy; + orderBy: SavedViewOrderBy; + orderDirection?: SavedViewOrderDirection; + showEmptyGroups: boolean; + states: StateApiResponse[]; + cycles: CycleApiResponse[]; + modules: ModuleApiResponse[]; + labels: LabelApiResponse[]; + members: WorkspaceMemberApiResponse[]; +}): SubGroupedIssuesResult | null { + const { groupBy, subGroupBy, labels } = params; + if (groupBy === 'none' || subGroupBy === 'none' || subGroupBy === groupBy) { + return null; + } + const primary = buildGroupedIssues({ ...params, groupBy }); + const sub = buildGroupedIssues({ ...params, groupBy: subGroupBy }); + if (primary.isFlat || sub.isFlat) return null; + + const cells = new Map>(); + for (const primaryKey of primary.order) { + const issues = primary.groups.get(primaryKey) ?? []; + const bySub = new Map(); + for (const issue of issues) { + const sk = subGroupKey(subGroupBy, issue, labels); + const arr = bySub.get(sk) ?? []; + arr.push(issue); + bySub.set(sk, arr); + } + cells.set(primaryKey, bySub); + } + return { + primaryOrder: primary.order, + primaryTitle: primary.title, + subOrder: sub.order, + subTitle: sub.title, + cells, + }; +} diff --git a/apps/web/src/lib/projectIssuesDisplay.ts b/apps/web/src/lib/projectIssuesDisplay.ts index d813111c..3dd66d5a 100644 --- a/apps/web/src/lib/projectIssuesDisplay.ts +++ b/apps/web/src/lib/projectIssuesDisplay.ts @@ -37,6 +37,7 @@ const ORDER_BY_OPTIONS: SavedViewOrderBy[] = [ export interface ProjectIssuesDisplayState { displayProperties: Set; groupBy: SavedViewGroupBy; + subGroupBy: SavedViewGroupBy; orderBy: SavedViewOrderBy; showSubWorkItems: boolean; showEmptyGroups: boolean; @@ -45,6 +46,7 @@ export interface ProjectIssuesDisplayState { export const DEFAULT_PROJECT_ISSUES_DISPLAY: ProjectIssuesDisplayState = { displayProperties: new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), groupBy: 'none', + subGroupBy: 'none', orderBy: 'last_created', showSubWorkItems: true, showEmptyGroups: true, @@ -54,12 +56,23 @@ export function cloneDefaultProjectIssuesDisplay(): ProjectIssuesDisplayState { return { displayProperties: new Set(DEFAULT_PROJECT_ISSUES_DISPLAY.displayProperties), groupBy: DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy, + subGroupBy: DEFAULT_PROJECT_ISSUES_DISPLAY.subGroupBy, orderBy: DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy, showSubWorkItems: DEFAULT_PROJECT_ISSUES_DISPLAY.showSubWorkItems, showEmptyGroups: DEFAULT_PROJECT_ISSUES_DISPLAY.showEmptyGroups, }; } +// A sub-group-by is only meaningful when it's a real dimension different from +// the primary group-by. Otherwise it collapses to a single sub-group. +export function normalizeSubGroupBy( + groupBy: SavedViewGroupBy, + subGroupBy: SavedViewGroupBy, +): SavedViewGroupBy { + if (groupBy === 'none' || subGroupBy === groupBy) return 'none'; + return subGroupBy; +} + function isValidPropertyId(x: string): x is SavedViewDisplayPropertyId { return (ALL_SAVED_VIEW_DISPLAY_PROPERTIES as string[]).includes(x); } @@ -67,6 +80,7 @@ function isValidPropertyId(x: string): x is SavedViewDisplayPropertyId { export interface PersistedProjectIssuesDisplay { displayProperties: string[]; groupBy: string; + subGroupBy?: string; orderBy: string; showSubWorkItems: boolean; showEmptyGroups?: boolean; @@ -85,12 +99,16 @@ export function parseProjectIssuesDisplay(raw: string | null): ProjectIssuesDisp const groupBy = GROUP_BY_OPTIONS.includes(p.groupBy as SavedViewGroupBy) ? (p.groupBy as SavedViewGroupBy) : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy; + const rawSubGroupBy = GROUP_BY_OPTIONS.includes(p.subGroupBy as SavedViewGroupBy) + ? (p.subGroupBy as SavedViewGroupBy) + : DEFAULT_PROJECT_ISSUES_DISPLAY.subGroupBy; const orderBy = ORDER_BY_OPTIONS.includes(p.orderBy as SavedViewOrderBy) ? (p.orderBy as SavedViewOrderBy) : DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy; return { displayProperties: props.size > 0 ? props : new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), groupBy, + subGroupBy: normalizeSubGroupBy(groupBy, rawSubGroupBy), orderBy, showSubWorkItems: p.showSubWorkItems !== undefined ? Boolean(p.showSubWorkItems) : true, showEmptyGroups: p.showEmptyGroups !== undefined ? Boolean(p.showEmptyGroups) : true, @@ -104,6 +122,7 @@ export function serializeProjectIssuesDisplay(s: ProjectIssuesDisplayState): str return JSON.stringify({ displayProperties: [...s.displayProperties], groupBy: s.groupBy, + subGroupBy: s.subGroupBy, orderBy: s.orderBy, showSubWorkItems: s.showSubWorkItems, showEmptyGroups: s.showEmptyGroups, @@ -118,6 +137,7 @@ export function toDisplayPayload(s: ProjectIssuesDisplayState): ProjectIssuesDis return { displayProperties: [...s.displayProperties], groupBy: s.groupBy, + subGroupBy: s.subGroupBy, orderBy: s.orderBy, showSubWorkItems: s.showSubWorkItems, showEmptyGroups: s.showEmptyGroups, @@ -129,11 +149,17 @@ export function fromDisplayPayload(p: ProjectIssuesDisplayPayload): ProjectIssue for (const id of p.displayProperties) { if (isValidPropertyId(id)) props.add(id); } + const groupBy = GROUP_BY_OPTIONS.includes(p.groupBy) + ? p.groupBy + : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy; + const rawSubGroupBy = + p.subGroupBy && GROUP_BY_OPTIONS.includes(p.subGroupBy) + ? p.subGroupBy + : DEFAULT_PROJECT_ISSUES_DISPLAY.subGroupBy; return { displayProperties: props.size > 0 ? props : new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), - groupBy: GROUP_BY_OPTIONS.includes(p.groupBy) - ? p.groupBy - : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy, + groupBy, + subGroupBy: normalizeSubGroupBy(groupBy, rawSubGroupBy), orderBy: ORDER_BY_OPTIONS.includes(p.orderBy) ? p.orderBy : DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy, diff --git a/apps/web/src/lib/projectIssuesEvents.ts b/apps/web/src/lib/projectIssuesEvents.ts index f0fe315d..192229e1 100644 --- a/apps/web/src/lib/projectIssuesEvents.ts +++ b/apps/web/src/lib/projectIssuesEvents.ts @@ -13,6 +13,7 @@ export const PROJECT_ISSUES_DISPLAY_EVENT = 'project-issues-display-change'; export interface ProjectIssuesDisplayPayload { displayProperties: SavedViewDisplayPropertyId[]; groupBy: SavedViewGroupBy; + subGroupBy?: SavedViewGroupBy; orderBy: SavedViewOrderBy; showSubWorkItems: boolean; showEmptyGroups: boolean; diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index fa059716..2c5f2135 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -31,7 +31,7 @@ import type { import type { Priority } from '../types'; import type { StateGroup } from '../types/workspaceViewFilters'; import type { SavedViewDisplayPropertyId } from '../lib/projectSavedViewDisplay'; -import { buildGroupedIssues } from '../lib/issueListGroupAndSort'; +import { buildGroupedIssues, buildSubGroupedIssues } from '../lib/issueListGroupAndSort'; import { cloneDefaultProjectIssuesDisplay, fromDisplayPayload, @@ -464,6 +464,37 @@ export function IssueListPage() { ], ); + // Optional second-level grouping (swimlanes). Null unless both a primary and + // a distinct secondary dimension are selected; layouts fall back to the flat + // groupedIssues when it's null. + const subGroupedIssues = useMemo( + () => + buildSubGroupedIssues({ + baseForGrouping, + groupBy: listDisplay.groupBy, + subGroupBy: listDisplay.subGroupBy, + orderBy: listDisplay.orderBy, + showEmptyGroups: listDisplay.showEmptyGroups, + states, + cycles, + modules, + labels, + members, + }), + [ + baseForGrouping, + listDisplay.groupBy, + listDisplay.subGroupBy, + listDisplay.orderBy, + listDisplay.showEmptyGroups, + states, + cycles, + modules, + labels, + members, + ], + ); + // Stable "now" timestamp used by overdue/relative-date cells. Sampled once // at mount via useState's lazy initializer (allowed to be impure) so each // row stays pure for the rest of the render-tree's lifetime. @@ -777,6 +808,7 @@ export function IssueListPage() {