From e2bfd09cf171afc228daf966ce6e29ba2efb5eac Mon Sep 17 00:00:00 2001 From: Aditya Work Date: Thu, 9 Jul 2026 00:17:17 +0200 Subject: [PATCH 1/2] feat(layouts): add subgroup and gantt controls Co-Authored-By: OpenAI Codex --- .../ProjectIssuesDisplayPanel.tsx | 32 +++- .../work-item/layouts/IssueLayoutBoard.tsx | 76 ++++++++- .../work-item/layouts/IssueLayoutGantt.tsx | 154 +++++++++++++++--- .../work-item/layouts/IssueLayoutList.tsx | 66 +++++++- .../work-item/layouts/IssueLayoutTypes.ts | 6 + apps/web/src/lib/moduleWorkItemsPrefs.ts | 2 + apps/web/src/lib/projectIssuesDisplay.ts | 15 ++ apps/web/src/lib/projectIssuesEvents.ts | 1 + apps/web/src/pages/IssueListPage.tsx | 52 +++++- 9 files changed, 363 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/project-issues/ProjectIssuesDisplayPanel.tsx b/apps/web/src/components/project-issues/ProjectIssuesDisplayPanel.tsx index f26d76b8..cfc499d9 100644 --- a/apps/web/src/components/project-issues/ProjectIssuesDisplayPanel.tsx +++ b/apps/web/src/components/project-issues/ProjectIssuesDisplayPanel.tsx @@ -48,7 +48,7 @@ const IconCheck = () => ( ); -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 }[] = [ @@ -136,6 +136,7 @@ export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssues const [sections, setSections] = useState>({ properties: true, group: true, + subGroup: true, order: true, }); @@ -195,7 +196,34 @@ 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: p.subGroupBy === v ? 'none' : p.subGroupBy, + })) + } + /> + ))} + + + + +
+ {GROUP_OPTIONS.filter( + (opt) => opt.value === 'none' || opt.value !== display.groupBy, + ).map((opt) => ( + setDisplay((p) => ({ ...p, subGroupBy: v }))} /> ))}
diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx index 8ccf0f73..c42cf3bf 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx @@ -17,6 +17,7 @@ import { } from '../EditableCells'; import { DatePickerTrigger } from '../DatePickerTrigger'; import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import { buildGroupedIssues } from '../../../lib/issueListGroupAndSort'; import type { IssueApiResponse, LabelApiResponse, @@ -24,6 +25,7 @@ import type { WorkspaceMemberApiResponse, } from '../../../api/types'; import type { Priority } from '../../../types'; +import type { SavedViewGroupBy, SavedViewOrderBy } from '../../../lib/projectSavedViewDisplay'; import { issueDisplayId, STATE_GROUP_LABELS, @@ -31,6 +33,12 @@ import { type IssueLayoutProps, } from './IssueLayoutTypes'; +interface IssueLayoutBoardProps extends IssueLayoutProps { + subGroupBy?: SavedViewGroupBy; + orderBy?: SavedViewOrderBy; + showEmptyGroups?: boolean; +} + /** * Kanban board grouped by state. One column per state, ordered by `sequence`, * cards reuse the same cells the list rows use. @@ -48,10 +56,15 @@ export function IssueLayoutBoard({ issueHref, now, projectsById, + cycles = [], + modules = [], + subGroupBy = 'none', + orderBy = 'manual', + showEmptyGroups = false, groupByStateGroup, onCardMove, onUpdateIssue, -}: IssueLayoutProps) { +}: IssueLayoutBoardProps) { const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); const issueById = useMemo(() => new Map(issues.map((i) => [i.id, i])), [issues]); @@ -166,6 +179,62 @@ export function IssueLayoutBoard({ /> ); + const buildColumnSwimlanes = (items: IssueApiResponse[]) => { + if (items.length === 0 || subGroupBy === 'none' || subGroupBy === 'states') return null; + const grouped = buildGroupedIssues({ + baseForGrouping: items, + groupBy: subGroupBy, + orderBy, + showEmptyGroups, + states, + cycles, + modules, + labels, + members, + }); + return grouped.isFlat ? null : grouped; + }; + + const renderColumnItems = (items: IssueApiResponse[]) => { + const swimlanes = buildColumnSwimlanes(items); + if (!swimlanes) { + return ( + <> + {items.map(renderCard)} + {items.length === 0 && ( +

No work items

+ )} + + ); + } + + return ( +
+ {swimlanes.order.map((laneKey) => { + const laneItems = swimlanes.groups.get(laneKey) ?? []; + if (laneItems.length === 0 && !showEmptyGroups) return null; + return ( +
+

+ {swimlanes.title(laneKey)} + {laneItems.length} +

+
+ {laneItems.length > 0 ? ( + laneItems.map(renderCard) + ) : ( +

+ No work items +

+ )} +
+
+ ); + })} +
+ ); + }; + // Whether a column accepts the in-flight card (skip its current column). const canDropOn = (columnKey: string): boolean => { if (!dndEnabled || !draggingId) return false; @@ -204,10 +273,7 @@ export function IssueLayoutBoard({ : undefined } > - {col.items.map(renderCard)} - {col.items.length === 0 && ( -

No work items

- )} + {renderColumnItems(col.items)} ))} diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx index f4c7f734..a5a75d29 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { PriorityIcon } from '../IssueRowCells'; @@ -6,7 +6,14 @@ import type { Priority } from '../../../types'; import { issueDisplayId, type IssueLayoutProps } from './IssueLayoutTypes'; const DAY_MS = 24 * 3600 * 1000; -const DAY_PX = 28; // width per day on the timeline; pannable, not zoomable yet +const ZOOM_LEVELS = { + day: { label: 'Day', dayPx: 28, stepDays: 7 }, + week: { label: 'Week', dayPx: 16, stepDays: 14 }, + month: { label: 'Month', dayPx: 8, stepDays: 30 }, +} as const; + +type GanttZoom = keyof typeof ZOOM_LEVELS; +type DragMode = 'move' | 'start' | 'end'; /** * Lightweight Gantt — horizontal timeline of bars positioned by start_date and @@ -16,8 +23,9 @@ const DAY_PX = 28; // width per day on the timeline; pannable, not zoomable yet * - We compute the visible window from min(start_date) to max(target_date) * across all dated issues, with a one-week padding either side. That keeps * the chart compact for short-running projects. - * - The user can shift the window by ±7 days with the prev/next controls. - * Real zoom + drag-to-reschedule are deferred. + * - The user can shift the window and switch day/week/month zoom levels. + * - Bars can be dragged to move, or resized from either edge, when an + * inline update handler is provided. * - Bar color comes from `state.color`. * - Sidebar (left) shows id + name; the chart (right) is horizontally * scrollable for projects whose range exceeds the viewport. @@ -29,6 +37,7 @@ export function IssueLayoutGantt({ issueHref, now, projectsById, + onUpdateIssue, }: IssueLayoutProps) { const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); @@ -39,8 +48,12 @@ export function IssueLayoutGantt({ const undated = useMemo(() => issues.filter((i) => !i.start_date || !i.target_date), [issues]); const [shiftDays, setShiftDays] = useState(0); + const [zoom, setZoom] = useState('day'); + const suppressClickRef = useRef(false); + const zoomConfig = ZOOM_LEVELS[zoom]; + const dayPx = zoomConfig.dayPx; - const window = useMemo(() => { + const timelineWindow = useMemo(() => { if (dated.length === 0) { const today = startOfDay(new Date(now)); return { start: today.getTime(), end: today.getTime() + 21 * DAY_MS }; @@ -61,22 +74,71 @@ export function IssueLayoutGantt({ return { start: min - pad + shiftDays * DAY_MS, end: max + pad + shiftDays * DAY_MS }; }, [dated, now, shiftDays]); - const totalDays = Math.max(1, Math.round((window.end - window.start) / DAY_MS) + 1); + const totalDays = Math.max( + 1, + Math.round((timelineWindow.end - timelineWindow.start) / DAY_MS) + 1, + ); const days = useMemo(() => { const arr: number[] = []; - for (let i = 0; i < totalDays; i++) arr.push(window.start + i * DAY_MS); + for (let i = 0; i < totalDays; i++) arr.push(timelineWindow.start + i * DAY_MS); return arr; - }, [window.start, totalDays]); + }, [timelineWindow.start, totalDays]); const todayMs = startOfDay(new Date(now)).getTime(); - const todayOffset = Math.round((todayMs - window.start) / DAY_MS); + const todayOffset = Math.round((todayMs - timelineWindow.start) / DAY_MS); + const editable = Boolean(onUpdateIssue); + + const startTimelineDrag = + (issue: (typeof dated)[number], mode: DragMode) => (event: React.PointerEvent) => { + if (!onUpdateIssue) return; + if (mode !== 'move') event.preventDefault(); + event.stopPropagation(); + const start = parseDay(issue.start_date!); + const end = parseDay(issue.target_date!); + if (start === null || end === null) return; + + const startX = event.clientX; + let deltaDays = 0; + const onPointerMove = (moveEvent: PointerEvent) => { + deltaDays = Math.round((moveEvent.clientX - startX) / dayPx); + }; + const onPointerUp = () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + if (deltaDays === 0) return; + + let nextStart = start; + let nextEnd = end; + if (mode === 'move') { + nextStart = addDaysMs(start, deltaDays); + nextEnd = addDaysMs(end, deltaDays); + } else if (mode === 'start') { + nextStart = Math.min(addDaysMs(start, deltaDays), nextEnd); + } else { + nextEnd = Math.max(addDaysMs(end, deltaDays), nextStart); + } + + if (nextStart === start && nextEnd === end) return; + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + onUpdateIssue(issue.id, { + start_date: formatInputDay(nextStart), + target_date: formatInputDay(nextEnd), + }); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + }; return (

- {fmtRange(window.start, window.end)} + {fmtRange(timelineWindow.start, timelineWindow.end)}

+
+ {(Object.keys(ZOOM_LEVELS) as GanttZoom[]).map((level) => ( + + ))} +
{dated.length} dated · {undated.length} undated @@ -142,7 +220,7 @@ export function IssueLayoutGantt({
{/* Timeline */} -
+
{/* Day-cell header */}
{days.map((ms, i) => { @@ -152,14 +230,14 @@ export function IssueLayoutGantt({
{isMonthStart && ( {d.toLocaleDateString(undefined, { month: 'short' })} )} - {d.getDate()} + {zoom !== 'month' && {d.getDate()}}
); })} @@ -169,7 +247,7 @@ export function IssueLayoutGantt({ {todayOffset >= 0 && todayOffset < totalDays && (
)} @@ -177,25 +255,46 @@ export function IssueLayoutGantt({ {/* Bars */}
    {dated.map((issue) => { - const start = parseDay(issue.start_date!) ?? window.start; + const start = parseDay(issue.start_date!) ?? timelineWindow.start; const end = parseDay(issue.target_date!) ?? start; - const offset = Math.max(0, Math.round((start - window.start) / DAY_MS)); + const offset = Math.max(0, Math.round((start - timelineWindow.start) / DAY_MS)); const span = Math.max(1, Math.round((end - start) / DAY_MS) + 1); const state = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; const color = state?.color || '#6b7280'; + const barWidth = Math.max(18, span * dayPx - 4); return (
  • { + if (suppressClickRef.current) event.preventDefault(); + }} + className={`absolute top-1.5 flex h-5 items-center overflow-hidden rounded-(--radius-md) px-2 text-[11px] font-medium text-white shadow-sm no-underline transition-opacity hover:opacity-80 ${ + editable ? 'cursor-grab active:cursor-grabbing' : '' + }`} style={{ - left: `${offset * DAY_PX + 2}px`, - width: `${span * DAY_PX - 4}px`, + left: `${offset * dayPx + 2}px`, + width: `${barWidth}px`, backgroundColor: color, }} title={`${issue.name} · ${issue.start_date} → ${issue.target_date}`} > + {editable && ( + + )} {issue.name} + {editable && ( + + )}
  • ); @@ -238,6 +337,10 @@ function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } +function addDaysMs(ms: number, days: number): number { + return ms + days * DAY_MS; +} + function parseDay(input: string): number | null { const t = Date.parse(input); if (Number.isNaN(t)) return null; @@ -256,3 +359,12 @@ function fmtRange(start: number, end: number): string { const eStr = e.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return `${sStr} – ${eStr}`; } + +function formatInputDay(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx index ed9f245f..00788e94 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx @@ -26,6 +26,8 @@ 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 result per primary group. */ + subGroupedIssues?: Map | null; /** * Filter columns (display properties) — true means render. Accepts the same * narrow `SavedViewDisplayPropertyId` keys the parent's `hasCol` checks; we @@ -63,6 +65,7 @@ export function IssueLayoutList({ issueHref, now, groupedIssues, + subGroupedIssues, hasCol, showEmptyGroups, subWorkCountByParentId, @@ -78,7 +81,7 @@ export function IssueLayoutList({ // Drag-to-reorder is only offered on the flat list (the parent decides whether // manual ordering is active by passing onReorder). - const reorderable = Boolean(onReorder && groupedIssues.isFlat); + const reorderable = Boolean(onReorder && groupedIssues.isFlat && !subGroupedIssues); const [draggingId, setDraggingId] = useState(null); const [dropTarget, setDropTarget] = useState<{ id: string; after: boolean } | null>(null); const clearDrag = () => { @@ -326,13 +329,60 @@ export function IssueLayoutList({ ); }; - if (groupedIssues.isFlat) { - const flatList = groupedIssues.groups.get(groupedIssues.order[0]) ?? []; + const renderList = (list: IssueApiResponse[], bordered: boolean, allowReorder: boolean) => ( +
      + {list.map((issue, idx) => + renderRow(issue, allowReorder ? idx : undefined, allowReorder ? list : undefined), + )} +
    + ); + + const renderSubGroups = ( + sectionKey: string, + sectionIssues: IssueApiResponse[], + allowFlatReorder: boolean, + ) => { + const subGroups = subGroupedIssues?.get(sectionKey); + if (!subGroups || subGroups.isFlat) { + return renderList(sectionIssues, !groupedIssues.isFlat, allowFlatReorder); + } return ( -
      - {flatList.map((issue, idx) => renderRow(issue, idx, flatList))} -
    +
    + {subGroups.order.map((subKey) => { + const subIssues = subGroups.groups.get(subKey) ?? []; + if (subIssues.length === 0 && !showEmptyGroups) return null; + return ( +
    +

    + {subGroups.title(subKey)} + {subIssues.length} +

    + {subIssues.length > 0 ? ( + renderList(subIssues, true, false) + ) : ( +

    + No work items +

    + )} +
    + ); + })} +
    ); + }; + + if (groupedIssues.isFlat) { + const flatKey = groupedIssues.order[0]; + const flatList = groupedIssues.groups.get(flatKey) ?? []; + if (subGroupedIssues) { + return
    {renderSubGroups(flatKey, flatList, false)}
    ; + } + return renderList(flatList, false, reorderable); } return ( @@ -347,9 +397,7 @@ export function IssueLayoutList({ {title} {sectionIssues.length} -
      - {sectionIssues.map((issue) => renderRow(issue))} -
    + {renderSubGroups(sectionKey, sectionIssues, false)} ); })} diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts index 727cae94..f1618c27 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts +++ b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts @@ -1,7 +1,9 @@ import type { GitHubIssueSummaryEntry, + CycleApiResponse, IssueApiResponse, LabelApiResponse, + ModuleApiResponse, ProjectApiResponse, StateApiResponse, WorkspaceMemberApiResponse, @@ -35,6 +37,10 @@ export interface IssueLayoutProps { labels: LabelApiResponse[]; /** Workspace members (for assignee avatars). */ members: WorkspaceMemberApiResponse[]; + /** Project cycles, when the layout needs to label cycle-based groupings. */ + cycles?: CycleApiResponse[]; + /** Project modules, when the layout needs to label module-based groupings. */ + modules?: ModuleApiResponse[]; /** github_issue_syncs aggregate per issue id. */ prSummary: Record; /** `${workspace}/projects/${project}` — used to build issue links. */ diff --git a/apps/web/src/lib/moduleWorkItemsPrefs.ts b/apps/web/src/lib/moduleWorkItemsPrefs.ts index ed5b3a86..50cf89f9 100644 --- a/apps/web/src/lib/moduleWorkItemsPrefs.ts +++ b/apps/web/src/lib/moduleWorkItemsPrefs.ts @@ -121,6 +121,7 @@ function normalizeModuleDisplay(raw: unknown): ProjectIssuesDisplayState { return fromDisplayPayload({ displayProperties: o.displayProperties as ProjectIssuesDisplayPayload['displayProperties'], groupBy: (o.groupBy as ProjectIssuesDisplayPayload['groupBy']) ?? 'none', + subGroupBy: (o.subGroupBy as ProjectIssuesDisplayPayload['subGroupBy']) ?? 'none', orderBy: (o.orderBy as ProjectIssuesDisplayPayload['orderBy']) ?? 'last_created', showSubWorkItems: o.showSubWorkItems !== undefined ? Boolean(o.showSubWorkItems) : true, showEmptyGroups: o.showEmptyGroups !== undefined ? Boolean(o.showEmptyGroups) : true, @@ -154,6 +155,7 @@ export function serializeModuleWorkItemsPrefs(p: PersistedModuleWorkItemsPrefs): display: { displayProperties: [...p.display.displayProperties], groupBy: p.display.groupBy, + subGroupBy: p.display.subGroupBy, orderBy: p.display.orderBy, showSubWorkItems: p.display.showSubWorkItems, showEmptyGroups: p.display.showEmptyGroups, diff --git a/apps/web/src/lib/projectIssuesDisplay.ts b/apps/web/src/lib/projectIssuesDisplay.ts index d813111c..f09b5339 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,6 +56,7 @@ 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, @@ -67,6 +70,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 +89,17 @@ 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 parsedSubGroupBy = GROUP_BY_OPTIONS.includes(p.subGroupBy as SavedViewGroupBy) + ? (p.subGroupBy as SavedViewGroupBy) + : DEFAULT_PROJECT_ISSUES_DISPLAY.subGroupBy; + const subGroupBy = parsedSubGroupBy === groupBy ? 'none' : parsedSubGroupBy; 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, orderBy, showSubWorkItems: p.showSubWorkItems !== undefined ? Boolean(p.showSubWorkItems) : true, showEmptyGroups: p.showEmptyGroups !== undefined ? Boolean(p.showEmptyGroups) : true, @@ -104,6 +113,7 @@ export function serializeProjectIssuesDisplay(s: ProjectIssuesDisplayState): str return JSON.stringify({ displayProperties: [...s.displayProperties], groupBy: s.groupBy, + subGroupBy: s.subGroupBy === s.groupBy ? 'none' : s.subGroupBy, orderBy: s.orderBy, showSubWorkItems: s.showSubWorkItems, showEmptyGroups: s.showEmptyGroups, @@ -118,6 +128,7 @@ export function toDisplayPayload(s: ProjectIssuesDisplayState): ProjectIssuesDis return { displayProperties: [...s.displayProperties], groupBy: s.groupBy, + subGroupBy: s.subGroupBy === s.groupBy ? 'none' : s.subGroupBy, orderBy: s.orderBy, showSubWorkItems: s.showSubWorkItems, showEmptyGroups: s.showEmptyGroups, @@ -134,6 +145,10 @@ export function fromDisplayPayload(p: ProjectIssuesDisplayPayload): ProjectIssue groupBy: GROUP_BY_OPTIONS.includes(p.groupBy) ? p.groupBy : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy, + subGroupBy: + GROUP_BY_OPTIONS.includes(p.subGroupBy) && p.subGroupBy !== p.groupBy + ? p.subGroupBy + : DEFAULT_PROJECT_ISSUES_DISPLAY.subGroupBy, 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..a825888c 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 54320e74..6990b79b 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -464,6 +464,41 @@ export function IssueListPage() { ], ); + const subGroupedIssues = useMemo(() => { + if (listDisplay.subGroupBy === 'none' || listDisplay.subGroupBy === listDisplay.groupBy) { + return null; + } + const nested = new Map(); + for (const sectionKey of groupedIssues.order) { + nested.set( + sectionKey, + buildGroupedIssues({ + baseForGrouping: groupedIssues.groups.get(sectionKey) ?? [], + groupBy: listDisplay.subGroupBy, + orderBy: listDisplay.orderBy, + showEmptyGroups: listDisplay.showEmptyGroups, + states, + cycles, + modules, + labels, + members, + }), + ); + } + return nested; + }, [ + groupedIssues, + listDisplay.groupBy, + listDisplay.orderBy, + listDisplay.showEmptyGroups, + listDisplay.subGroupBy, + 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. @@ -557,15 +592,18 @@ export function IssueListPage() { const layout = parseIssueLayout(searchParams.get('layout')); const issueHref = (id: string) => `${baseUrl}/issues/${id}`; + const orderedVisibleIssues = groupedIssues.order.flatMap( + (sectionKey) => groupedIssues.groups.get(sectionKey) ?? [], + ); const layoutProps = { workspaceSlug: workspace.slug, project, - issues: groupedIssues.isFlat - ? (groupedIssues.groups.get(groupedIssues.order[0]) ?? []) - : filteredIssues, + issues: orderedVisibleIssues, states, labels, members, + cycles, + modules, prSummary, baseUrl, issueHref, @@ -768,6 +806,7 @@ export function IssueListPage() { @@ -789,7 +831,9 @@ export function IssueListPage() { )} {layout === 'calendar' && } - {layout === 'gantt' && } + {layout === 'gantt' && ( + + )} {layout === 'list' && (