Skip to content

Commit c745572

Browse files
authored
Merge pull request #289 from aditya-vithaldas/agent/good-first-layout-fixes
feat(layouts): improve work item layout controls
2 parents 6902ac0 + f6e3f5a commit c745572

4 files changed

Lines changed: 759 additions & 261 deletions

File tree

apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx

Lines changed: 187 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
import { DatePickerTrigger } from '../DatePickerTrigger';
1919
import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers';
2020
import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort';
21-
import type { SavedViewGroupBy } from '../../../lib/projectSavedViewDisplay';
21+
import type {
22+
SavedViewDisplayPropertyId,
23+
SavedViewGroupBy,
24+
} from '../../../lib/projectSavedViewDisplay';
2225
import type {
2326
IssueApiResponse,
2427
LabelApiResponse,
@@ -33,15 +36,18 @@ import {
3336
type IssueLayoutProps,
3437
} from './IssueLayoutTypes';
3538

36-
/**
37-
* Kanban board. By default it groups by state; when the parent provides a
38-
* display grouping result, columns follow the selected group-by setting.
39-
*/
4039
interface IssueLayoutBoardProps extends IssueLayoutProps {
4140
groupedIssues?: GroupedIssuesResult;
41+
hasCol?: (key: SavedViewDisplayPropertyId) => boolean;
4242
groupBy?: SavedViewGroupBy;
43+
showEmptyGroups?: boolean;
44+
subWorkCountByParentId?: Map<string, number>;
45+
cycleName?: (issue: IssueApiResponse) => string;
46+
moduleName?: (issue: IssueApiResponse) => string;
4347
}
4448

49+
const defaultHasCol = () => true;
50+
4551
export function IssueLayoutBoard({
4652
project,
4753
states,
@@ -52,15 +58,21 @@ export function IssueLayoutBoard({
5258
issueHref,
5359
now,
5460
projectsById,
55-
groupByStateGroup,
5661
groupedIssues,
62+
hasCol: hasColProp,
5763
groupBy,
64+
showEmptyGroups = false,
65+
subWorkCountByParentId,
66+
cycleName,
67+
moduleName,
68+
groupByStateGroup,
5869
onCardMove,
5970
onUpdateIssue,
6071
}: IssueLayoutBoardProps) {
6172
const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]);
6273
const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]);
6374
const issueById = useMemo(() => new Map(issues.map((i) => [i.id, i])), [issues]);
75+
const hasCol = hasColProp ?? defaultHasCol;
6476

6577
const [draggingId, setDraggingId] = useState<string | null>(null);
6678
const [dropKey, setDropKey] = useState<string | null>(null);
@@ -95,12 +107,17 @@ export function IssueLayoutBoard({
95107
// one column per individual state.
96108
const { columns, orphans } = useMemo(() => {
97109
if (groupedIssues) {
98-
const columns = groupedIssues.order.map((key) => ({
99-
key,
100-
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
101-
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
102-
items: groupedIssues.groups.get(key) ?? [],
103-
}));
110+
const columns = groupedIssues.order
111+
.map((key) => {
112+
const items = groupedIssues.groups.get(key) ?? [];
113+
return {
114+
key,
115+
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
116+
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
117+
items,
118+
};
119+
})
120+
.filter((col) => groupedIssues.isFlat || showEmptyGroups || col.items.length > 0);
104121
return { columns, orphans: [] as IssueApiResponse[] };
105122
}
106123

@@ -149,7 +166,7 @@ export function IssueLayoutBoard({
149166
items: buckets.get(s.id) ?? [],
150167
}));
151168
return { columns, orphans };
152-
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById]);
169+
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById, showEmptyGroups]);
153170

154171
const dndEnabled =
155172
Boolean(onCardMove) && (groupByStateGroup || !groupedIssues || groupBy === 'states');
@@ -181,6 +198,10 @@ export function IssueLayoutBoard({
181198
onUpdateIssue={onUpdateIssue}
182199
openId={openCell}
183200
onOpenCell={setOpenCell}
201+
hasCol={hasCol}
202+
subWorkCount={subWorkCountByParentId?.get(issue.id) ?? 0}
203+
cycleName={cycleName?.(issue) ?? '—'}
204+
moduleName={moduleName?.(issue) ?? '—'}
184205
/>
185206
);
186207

@@ -305,6 +326,10 @@ interface BoardCardProps {
305326
onUpdateIssue?: IssueLayoutProps['onUpdateIssue'];
306327
openId: string | null;
307328
onOpenCell: (id: string | null) => void;
329+
hasCol: (key: SavedViewDisplayPropertyId) => boolean;
330+
subWorkCount: number;
331+
cycleName: string;
332+
moduleName: string;
308333
}
309334

310335
// Wraps an interactive control inside the card's navigating Link so clicking it
@@ -344,9 +369,27 @@ function BoardCard({
344369
onUpdateIssue,
345370
openId,
346371
onOpenCell,
372+
hasCol,
373+
subWorkCount,
374+
cycleName,
375+
moduleName,
347376
}: BoardCardProps) {
348377
const displayId = issueDisplayId(issue, project, projectsById);
349378
const editable = Boolean(onUpdateIssue);
379+
const startStr = formatShort(issue.start_date);
380+
const showPriority = hasCol('priority');
381+
const showId = hasCol('id');
382+
const showState = hasCol('state');
383+
const showStart = hasCol('start_date');
384+
const showDue = hasCol('due_date');
385+
const showLabels = hasCol('labels');
386+
const showAssignee = hasCol('assignee');
387+
const showSubWorkCount = hasCol('sub_work_count') && subWorkCount > 0;
388+
const showCycle = hasCol('cycle') && cycleName !== '—';
389+
const showModule = hasCol('module') && moduleName !== '—';
390+
const showProperties =
391+
showState || showStart || showDue || showLabels || showSubWorkCount || showCycle || showModule;
392+
350393
return (
351394
<Link
352395
to={href}
@@ -362,95 +405,150 @@ function BoardCard({
362405
} ${isDragging ? 'opacity-50' : ''}`}
363406
>
364407
<div className="flex items-start gap-2">
365-
{editable && onUpdateIssue ? (
366-
<CellGuard>
367-
<EditablePriorityCell
368-
issueId={issue.id}
369-
priority={issue.priority}
370-
openId={openId}
371-
onOpen={onOpenCell}
372-
onChange={(priority) => onUpdateIssue(issue.id, { priority })}
373-
/>
374-
</CellGuard>
375-
) : (
376-
<PriorityIcon priority={issue.priority as Priority | null | undefined} />
377-
)}
408+
{showPriority ? (
409+
editable && onUpdateIssue ? (
410+
<CellGuard>
411+
<EditablePriorityCell
412+
issueId={issue.id}
413+
priority={issue.priority}
414+
openId={openId}
415+
onOpen={onOpenCell}
416+
onChange={(priority) => onUpdateIssue(issue.id, { priority })}
417+
/>
418+
</CellGuard>
419+
) : (
420+
<PriorityIcon priority={issue.priority as Priority | null | undefined} />
421+
)
422+
) : null}
378423
<div className="min-w-0 flex-1">
379-
<div className="flex items-center gap-1.5 text-[11px] text-(--txt-tertiary)">
380-
<span className="font-medium text-(--txt-accent-primary)">{displayId}</span>
381-
<IssuePRBadge summary={prSummary} />
382-
</div>
424+
{(showId || prSummary) && (
425+
<div className="flex items-center gap-1.5 text-[11px] text-(--txt-tertiary)">
426+
{showId ? (
427+
<span className="font-medium text-(--txt-accent-primary)">{displayId}</span>
428+
) : null}
429+
<IssuePRBadge summary={prSummary} />
430+
</div>
431+
)}
383432
<p className="mt-0.5 line-clamp-2 text-sm font-medium text-(--txt-primary)">
384433
{issue.name}
385434
</p>
386435
</div>
387436
</div>
388437

389-
<div className="mt-2 flex flex-wrap items-center gap-1.5">
390-
{editable && onUpdateIssue ? (
391-
<>
392-
{state && (
393-
<CellGuard>
394-
<EditableStateCell
395-
issueId={issue.id}
396-
state={state}
397-
states={allStates}
398-
openId={openId}
399-
onOpen={onOpenCell}
400-
onChange={(state_id) => onUpdateIssue(issue.id, { state_id })}
401-
/>
402-
</CellGuard>
403-
)}
404-
<CellGuard>
405-
<DatePickerTrigger
406-
label="Due date"
407-
icon={<Calendar />}
408-
value={issue.target_date ?? ''}
409-
placeholder="Due"
410-
className={
411-
isOverdue(issue.target_date, state?.group, now)
412-
? 'border-(--border-danger-strong) text-(--txt-danger-primary)'
413-
: undefined
414-
}
415-
onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })}
416-
/>
417-
</CellGuard>
438+
{showProperties ? (
439+
<div className="mt-2 flex flex-wrap items-center gap-1.5">
440+
{editable && onUpdateIssue ? (
441+
<>
442+
{showState ? (
443+
<CellGuard>
444+
<EditableStateCell
445+
issueId={issue.id}
446+
state={state}
447+
states={allStates}
448+
openId={openId}
449+
onOpen={onOpenCell}
450+
onChange={(state_id) => onUpdateIssue(issue.id, { state_id })}
451+
/>
452+
</CellGuard>
453+
) : null}
454+
{showStart ? (
455+
<CellGuard>
456+
<DatePickerTrigger
457+
label="Start date"
458+
icon={<Calendar />}
459+
value={issue.start_date ?? ''}
460+
placeholder="Start"
461+
onChange={(v) => onUpdateIssue(issue.id, { start_date: v || null })}
462+
/>
463+
</CellGuard>
464+
) : null}
465+
{showDue ? (
466+
<CellGuard>
467+
<DatePickerTrigger
468+
label="Due date"
469+
icon={<Calendar />}
470+
value={issue.target_date ?? ''}
471+
placeholder="Due"
472+
className={
473+
isOverdue(issue.target_date, state?.group, now)
474+
? 'border-(--border-danger-strong) text-(--txt-danger-primary)'
475+
: undefined
476+
}
477+
onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })}
478+
/>
479+
</CellGuard>
480+
) : null}
481+
{showLabels ? (
482+
<CellGuard>
483+
<EditableLabelCell
484+
issueId={issue.id}
485+
labelIds={issue.label_ids ?? []}
486+
labels={allLabels}
487+
openId={openId}
488+
onOpen={onOpenCell}
489+
onChange={(label_ids) => onUpdateIssue(issue.id, { label_ids })}
490+
/>
491+
</CellGuard>
492+
) : null}
493+
</>
494+
) : (
495+
<>
496+
{showLabels && labels.length > 0 ? <LabelChips labels={labels} max={2} /> : null}
497+
{showStart && startStr ? (
498+
<span className="rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-1.5 py-0.5 text-[11px] text-(--txt-secondary)">
499+
{startStr}
500+
</span>
501+
) : null}
502+
{showDue ? <DueDateCell issue={issue} state={state} now={now} /> : null}
503+
{showState ? <StatePill state={state} /> : null}
504+
</>
505+
)}
506+
{showSubWorkCount ? (
507+
<span
508+
className="inline-flex h-5 items-center rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-1.5 text-[11px] text-(--txt-secondary)"
509+
title="Sub-work items"
510+
>
511+
{subWorkCount}
512+
</span>
513+
) : null}
514+
{showCycle ? (
515+
<span className="max-w-[7rem] truncate text-[11px] text-(--txt-secondary)">
516+
{cycleName}
517+
</span>
518+
) : null}
519+
{showModule ? (
520+
<span className="max-w-[7rem] truncate text-[11px] text-(--txt-secondary)">
521+
{moduleName}
522+
</span>
523+
) : null}
524+
</div>
525+
) : null}
526+
527+
{showAssignee ? (
528+
<div className="mt-2 flex items-center justify-between">
529+
{editable && onUpdateIssue ? (
418530
<CellGuard>
419-
<EditableLabelCell
531+
<EditableAssigneeCell
420532
issueId={issue.id}
421-
labelIds={issue.label_ids ?? []}
422-
labels={allLabels}
533+
assigneeIds={issue.assignee_ids ?? []}
534+
members={allMembers}
423535
openId={openId}
424536
onOpen={onOpenCell}
425-
onChange={(label_ids) => onUpdateIssue(issue.id, { label_ids })}
537+
onChange={(assignee_ids) => onUpdateIssue(issue.id, { assignee_ids })}
426538
/>
427539
</CellGuard>
428-
</>
429-
) : (
430-
<>
431-
{labels.length > 0 && <LabelChips labels={labels} max={2} />}
432-
<DueDateCell issue={issue} state={state} now={now} />
433-
{state && <StatePill state={state} />}
434-
</>
435-
)}
436-
</div>
437-
438-
<div className="mt-2 flex items-center justify-between">
439-
{editable && onUpdateIssue ? (
440-
<CellGuard>
441-
<EditableAssigneeCell
442-
issueId={issue.id}
443-
assigneeIds={issue.assignee_ids ?? []}
444-
members={allMembers}
445-
openId={openId}
446-
onOpen={onOpenCell}
447-
onChange={(assignee_ids) => onUpdateIssue(issue.id, { assignee_ids })}
448-
/>
449-
</CellGuard>
450-
) : (
451-
<WorkItemAvatarGroup members={assignees} max={3} />
452-
)}
453-
</div>
540+
) : (
541+
<WorkItemAvatarGroup members={assignees} max={3} />
542+
)}
543+
</div>
544+
) : null}
454545
</Link>
455546
);
456547
}
548+
549+
function formatShort(iso: string | null | undefined): string | null {
550+
if (!iso?.trim()) return null;
551+
const t = Date.parse(iso);
552+
if (Number.isNaN(t)) return null;
553+
return new Date(t).toLocaleDateString();
554+
}

0 commit comments

Comments
 (0)