Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/src/components/layout/ModuleDetailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,11 @@ export function ModuleDetailHeader({
</span>
}
>
<ProjectIssuesDisplayPanel display={display} setDisplay={setDisplay} />
<ProjectIssuesDisplayPanel
display={display}
setDisplay={setDisplay}
enableSubGroup={false}
/>
</Dropdown>

<Link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
ALL_SAVED_VIEW_DISPLAY_PROPERTIES,
SAVED_VIEW_DISPLAY_PROPERTY_LABELS,
} from '../../lib/projectSavedViewDisplay';
import type { ProjectIssuesDisplayState } from '../../lib/projectIssuesDisplay';
import {
normalizeSubGroupBy,
type ProjectIssuesDisplayState,
} from '../../lib/projectIssuesDisplay';

const IconChevronDown = () => (
<svg
Expand Down Expand Up @@ -48,7 +51,7 @@ const IconCheck = () => (
</svg>
);

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 }[] = [
Expand Down Expand Up @@ -130,15 +133,28 @@ const displayPanelCheckboxClass =
export interface ProjectIssuesDisplayPanelProps {
display: ProjectIssuesDisplayState;
setDisplay: React.Dispatch<React.SetStateAction<ProjectIssuesDisplayState>>;
/** 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<Record<SectionId, boolean>>({
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] }));
};
Expand Down Expand Up @@ -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),
}))
}
/>
))}
</div>
</CollapsibleSection>

{enableSubGroup && display.groupBy !== 'none' && (
<CollapsibleSection
id="subgroup"
title="Sub-group by"
expanded={sections.subgroup}
onToggle={toggleSection}
>
<div className="flex flex-col gap-0.5">
{subGroupOptions.map((opt) => (
<RadioRow
key={opt.value}
value={opt.value}
label={opt.label}
selected={display.subGroupBy === opt.value}
onSelect={(v) =>
setDisplay((p) => ({ ...p, subGroupBy: normalizeSubGroupBy(p.groupBy, v) }))
}
/>
))}
</div>
</CollapsibleSection>
)}

<CollapsibleSection
id="order"
title="Order by"
Expand Down
60 changes: 58 additions & 2 deletions apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
} from '../EditableCells';
import { DatePickerTrigger } from '../DatePickerTrigger';
import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers';
import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort';
import type {
GroupedIssuesResult,
SubGroupedIssuesResult,
} from '../../../lib/issueListGroupAndSort';
import type {
SavedViewDisplayPropertyId,
SavedViewGroupBy,
Expand All @@ -38,6 +41,8 @@ import {

interface IssueLayoutBoardProps extends IssueLayoutProps {
groupedIssues?: GroupedIssuesResult;
/** Optional second-level grouping; when present the board renders swimlanes. */
subGroupedIssues?: SubGroupedIssuesResult | null;
hasCol?: (key: SavedViewDisplayPropertyId) => boolean;
groupBy?: SavedViewGroupBy;
showEmptyGroups?: boolean;
Expand All @@ -59,6 +64,7 @@ export function IssueLayoutBoard({
now,
projectsById,
groupedIssues,
subGroupedIssues,
hasCol: hasColProp,
groupBy,
showEmptyGroups = false,
Expand Down Expand Up @@ -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) => (
<BoardCard
Expand Down Expand Up @@ -214,6 +224,52 @@ export function IssueLayoutBoard({
return Boolean(target) && target !== issue.state_id;
};

// Swimlanes: one horizontal band of columns (primary groups) per sub-group.
if (subGroupedIssues) {
const sg = subGroupedIssues;
return (
<div className="space-y-6 px-4 py-4">
{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 (
<section key={subKey} className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-(--txt-primary)">
{sg.subTitle(subKey)}
<span className="font-normal text-(--txt-tertiary)">{laneCount}</span>
</h3>
<div className="flex gap-3 overflow-x-auto">
{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 (
<BoardColumn
key={pk}
title={sg.primaryTitle(pk)}
color={color}
count={items.length}
>
{items.map(renderCard)}
{items.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-(--txt-tertiary)">
No work items
</p>
)}
</BoardColumn>
);
})}
</div>
</section>
);
})}
</div>
);
}

return (
<div className="flex gap-3 overflow-x-auto px-4 py-4">
{columns.map((col) => (
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/components/work-item/layouts/IssueLayoutList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +68,7 @@ export function IssueLayoutList({
issueHref,
now,
groupedIssues,
subGroupedIssues,
hasCol,
showEmptyGroups,
subWorkCountByParentId,
Expand Down Expand Up @@ -335,6 +341,50 @@ export function IssueLayoutList({
);
}

// Nested (sub-grouped) rendering: each primary group holds sub-group sections.
if (subGroupedIssues) {
const sg = subGroupedIssues;
return (
<div className="space-y-8 px-4 py-4">
{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 (
<section key={primaryKey} className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-(--txt-primary)">
{sg.primaryTitle(primaryKey)}
<span className="font-normal text-(--txt-tertiary)">{primaryCount}</span>
</h3>
<div className="space-y-3 border-l-2 border-(--border-subtle) pl-3">
{sg.subOrder.map((subKey) => {
const cellIssues = bySub?.get(subKey) ?? [];
if (cellIssues.length === 0 && !showEmptyGroups) return null;
return (
<section key={subKey} className="space-y-1.5">
<h4 className="flex items-center gap-2 text-xs font-medium text-(--txt-secondary)">
{sg.subTitle(subKey)}
<span className="font-normal text-(--txt-tertiary)">
{cellIssues.length}
</span>
</h4>
<ul className="w-full divide-y divide-(--border-subtle) rounded-md border border-(--border-subtle) bg-(--bg-surface-1)">
{cellIssues.map((issue) => renderRow(issue))}
</ul>
</section>
);
})}
</div>
</section>
);
})}
</div>
);
}

return (
<div className="space-y-6 px-4 py-4">
{groupedIssues.order.map((sectionKey) => {
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/lib/issueListGroupAndSort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Map<string, IssueApiResponse[]>>;
}

// 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<string, Map<string, IssueApiResponse[]>>();
for (const primaryKey of primary.order) {
const issues = primary.groups.get(primaryKey) ?? [];
const bySub = new Map<string, IssueApiResponse[]>();
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,
};
}
Loading
Loading