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
276 changes: 187 additions & 89 deletions apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
import { DatePickerTrigger } from '../DatePickerTrigger';
import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers';
import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort';
import type { SavedViewGroupBy } from '../../../lib/projectSavedViewDisplay';
import type {
SavedViewDisplayPropertyId,
SavedViewGroupBy,
} from '../../../lib/projectSavedViewDisplay';
import type {
IssueApiResponse,
LabelApiResponse,
Expand All @@ -33,15 +36,18 @@ import {
type IssueLayoutProps,
} from './IssueLayoutTypes';

/**
* Kanban board. By default it groups by state; when the parent provides a
* display grouping result, columns follow the selected group-by setting.
*/
interface IssueLayoutBoardProps extends IssueLayoutProps {
groupedIssues?: GroupedIssuesResult;
hasCol?: (key: SavedViewDisplayPropertyId) => boolean;
groupBy?: SavedViewGroupBy;
showEmptyGroups?: boolean;
subWorkCountByParentId?: Map<string, number>;
cycleName?: (issue: IssueApiResponse) => string;
moduleName?: (issue: IssueApiResponse) => string;
}

const defaultHasCol = () => true;

export function IssueLayoutBoard({
project,
states,
Expand All @@ -52,15 +58,21 @@ export function IssueLayoutBoard({
issueHref,
now,
projectsById,
groupByStateGroup,
groupedIssues,
hasCol: hasColProp,
groupBy,
showEmptyGroups = false,
subWorkCountByParentId,
cycleName,
moduleName,
groupByStateGroup,
onCardMove,
onUpdateIssue,
}: 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]);
const hasCol = hasColProp ?? defaultHasCol;

const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropKey, setDropKey] = useState<string | null>(null);
Expand Down Expand Up @@ -95,12 +107,17 @@ export function IssueLayoutBoard({
// one column per individual state.
const { columns, orphans } = useMemo(() => {
if (groupedIssues) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve state columns for ungrouped boards

When groupedIssues is supplied, this branch also handles the flat groupBy: 'none' case; project issue display defaults to groupBy: 'none' (projectIssuesDisplay.ts), so the default board now renders one __all__/“All work items” column instead of the previous per-state Kanban columns. Because that column key is not a state id, dndEnabled is false as well, so users who simply switch to Board lose drag-to-state behavior until they manually change grouping to States.

Useful? React with 👍 / 👎.

const columns = groupedIssues.order.map((key) => ({
key,
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
items: groupedIssues.groups.get(key) ?? [],
}));
const columns = groupedIssues.order
.map((key) => {
const items = groupedIssues.groups.get(key) ?? [];
return {
key,
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
items,
};
})
.filter((col) => groupedIssues.isFlat || showEmptyGroups || col.items.length > 0);
return { columns, orphans: [] as IssueApiResponse[] };
}

Expand Down Expand Up @@ -149,7 +166,7 @@ export function IssueLayoutBoard({
items: buckets.get(s.id) ?? [],
}));
return { columns, orphans };
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById]);
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById, showEmptyGroups]);

const dndEnabled =
Boolean(onCardMove) && (groupByStateGroup || !groupedIssues || groupBy === 'states');
Expand Down Expand Up @@ -181,6 +198,10 @@ export function IssueLayoutBoard({
onUpdateIssue={onUpdateIssue}
openId={openCell}
onOpenCell={setOpenCell}
hasCol={hasCol}
subWorkCount={subWorkCountByParentId?.get(issue.id) ?? 0}
cycleName={cycleName?.(issue) ?? '—'}
moduleName={moduleName?.(issue) ?? '—'}
/>
);

Expand Down Expand Up @@ -305,6 +326,10 @@ interface BoardCardProps {
onUpdateIssue?: IssueLayoutProps['onUpdateIssue'];
openId: string | null;
onOpenCell: (id: string | null) => void;
hasCol: (key: SavedViewDisplayPropertyId) => boolean;
subWorkCount: number;
cycleName: string;
moduleName: string;
}

// Wraps an interactive control inside the card's navigating Link so clicking it
Expand Down Expand Up @@ -344,9 +369,27 @@ function BoardCard({
onUpdateIssue,
openId,
onOpenCell,
hasCol,
subWorkCount,
cycleName,
moduleName,
}: BoardCardProps) {
const displayId = issueDisplayId(issue, project, projectsById);
const editable = Boolean(onUpdateIssue);
const startStr = formatShort(issue.start_date);
const showPriority = hasCol('priority');
const showId = hasCol('id');
const showState = hasCol('state');
const showStart = hasCol('start_date');
const showDue = hasCol('due_date');
const showLabels = hasCol('labels');
const showAssignee = hasCol('assignee');
const showSubWorkCount = hasCol('sub_work_count') && subWorkCount > 0;
const showCycle = hasCol('cycle') && cycleName !== '—';
const showModule = hasCol('module') && moduleName !== '—';
const showProperties =
showState || showStart || showDue || showLabels || showSubWorkCount || showCycle || showModule;

return (
<Link
to={href}
Expand All @@ -362,95 +405,150 @@ function BoardCard({
} ${isDragging ? 'opacity-50' : ''}`}
>
<div className="flex items-start gap-2">
{editable && onUpdateIssue ? (
<CellGuard>
<EditablePriorityCell
issueId={issue.id}
priority={issue.priority}
openId={openId}
onOpen={onOpenCell}
onChange={(priority) => onUpdateIssue(issue.id, { priority })}
/>
</CellGuard>
) : (
<PriorityIcon priority={issue.priority as Priority | null | undefined} />
)}
{showPriority ? (
editable && onUpdateIssue ? (
<CellGuard>
<EditablePriorityCell
issueId={issue.id}
priority={issue.priority}
openId={openId}
onOpen={onOpenCell}
onChange={(priority) => onUpdateIssue(issue.id, { priority })}
/>
</CellGuard>
) : (
<PriorityIcon priority={issue.priority as Priority | null | undefined} />
)
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[11px] text-(--txt-tertiary)">
<span className="font-medium text-(--txt-accent-primary)">{displayId}</span>
<IssuePRBadge summary={prSummary} />
</div>
{(showId || prSummary) && (
<div className="flex items-center gap-1.5 text-[11px] text-(--txt-tertiary)">
{showId ? (
<span className="font-medium text-(--txt-accent-primary)">{displayId}</span>
) : null}
<IssuePRBadge summary={prSummary} />
</div>
)}
<p className="mt-0.5 line-clamp-2 text-sm font-medium text-(--txt-primary)">
{issue.name}
</p>
</div>
</div>

<div className="mt-2 flex flex-wrap items-center gap-1.5">
{editable && onUpdateIssue ? (
<>
{state && (
<CellGuard>
<EditableStateCell
issueId={issue.id}
state={state}
states={allStates}
openId={openId}
onOpen={onOpenCell}
onChange={(state_id) => onUpdateIssue(issue.id, { state_id })}
/>
</CellGuard>
)}
<CellGuard>
<DatePickerTrigger
label="Due date"
icon={<Calendar />}
value={issue.target_date ?? ''}
placeholder="Due"
className={
isOverdue(issue.target_date, state?.group, now)
? 'border-(--border-danger-strong) text-(--txt-danger-primary)'
: undefined
}
onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })}
/>
</CellGuard>
{showProperties ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{editable && onUpdateIssue ? (
<>
{showState ? (
<CellGuard>
<EditableStateCell
issueId={issue.id}
state={state}
states={allStates}
openId={openId}
onOpen={onOpenCell}
onChange={(state_id) => onUpdateIssue(issue.id, { state_id })}
/>
</CellGuard>
) : null}
{showStart ? (
<CellGuard>
<DatePickerTrigger
label="Start date"
icon={<Calendar />}
value={issue.start_date ?? ''}
placeholder="Start"
onChange={(v) => onUpdateIssue(issue.id, { start_date: v || null })}
/>
</CellGuard>
) : null}
{showDue ? (
<CellGuard>
<DatePickerTrigger
label="Due date"
icon={<Calendar />}
value={issue.target_date ?? ''}
placeholder="Due"
className={
isOverdue(issue.target_date, state?.group, now)
? 'border-(--border-danger-strong) text-(--txt-danger-primary)'
: undefined
}
onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })}
/>
</CellGuard>
) : null}
{showLabels ? (
<CellGuard>
<EditableLabelCell
issueId={issue.id}
labelIds={issue.label_ids ?? []}
labels={allLabels}
openId={openId}
onOpen={onOpenCell}
onChange={(label_ids) => onUpdateIssue(issue.id, { label_ids })}
/>
</CellGuard>
) : null}
</>
) : (
<>
{showLabels && labels.length > 0 ? <LabelChips labels={labels} max={2} /> : null}
{showStart && startStr ? (
<span className="rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-1.5 py-0.5 text-[11px] text-(--txt-secondary)">
{startStr}
</span>
) : null}
{showDue ? <DueDateCell issue={issue} state={state} now={now} /> : null}
{showState ? <StatePill state={state} /> : null}
</>
)}
{showSubWorkCount ? (
<span
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)"
title="Sub-work items"
>
{subWorkCount}
</span>
) : null}
{showCycle ? (
<span className="max-w-[7rem] truncate text-[11px] text-(--txt-secondary)">
{cycleName}
</span>
) : null}
{showModule ? (
<span className="max-w-[7rem] truncate text-[11px] text-(--txt-secondary)">
{moduleName}
</span>
) : null}
</div>
) : null}

{showAssignee ? (
<div className="mt-2 flex items-center justify-between">
{editable && onUpdateIssue ? (
<CellGuard>
<EditableLabelCell
<EditableAssigneeCell
issueId={issue.id}
labelIds={issue.label_ids ?? []}
labels={allLabels}
assigneeIds={issue.assignee_ids ?? []}
members={allMembers}
openId={openId}
onOpen={onOpenCell}
onChange={(label_ids) => onUpdateIssue(issue.id, { label_ids })}
onChange={(assignee_ids) => onUpdateIssue(issue.id, { assignee_ids })}
/>
</CellGuard>
</>
) : (
<>
{labels.length > 0 && <LabelChips labels={labels} max={2} />}
<DueDateCell issue={issue} state={state} now={now} />
{state && <StatePill state={state} />}
</>
)}
</div>

<div className="mt-2 flex items-center justify-between">
{editable && onUpdateIssue ? (
<CellGuard>
<EditableAssigneeCell
issueId={issue.id}
assigneeIds={issue.assignee_ids ?? []}
members={allMembers}
openId={openId}
onOpen={onOpenCell}
onChange={(assignee_ids) => onUpdateIssue(issue.id, { assignee_ids })}
/>
</CellGuard>
) : (
<WorkItemAvatarGroup members={assignees} max={3} />
)}
</div>
) : (
<WorkItemAvatarGroup members={assignees} max={3} />
)}
</div>
) : null}
</Link>
);
}

function formatShort(iso: string | null | undefined): string | null {
if (!iso?.trim()) return null;
const t = Date.parse(iso);
if (Number.isNaN(t)) return null;
return new Date(t).toLocaleDateString();
}
Loading
Loading