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
112 changes: 108 additions & 4 deletions apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { IssuePRBadge } from '../IssuePRBadge';
import {
Expand Down Expand Up @@ -36,9 +36,38 @@ export function IssueLayoutBoard({
now,
projectsById,
groupByStateGroup,
onCardMove,
}: IssueLayoutProps) {
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 dndEnabled = Boolean(onCardMove);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropKey, setDropKey] = useState<string | null>(null);

// Map a column to the concrete state an issue should land in. For per-state
// columns the key is already a state id; for grouped columns we pick a state
// in the issue's own project that belongs to that group (default first).
const resolveTargetStateId = (columnKey: string, issue: IssueApiResponse): string | null => {
if (!groupByStateGroup) return columnKey;
const candidates = states.filter(
(s) => s.group === columnKey && s.project_id === issue.project_id,
);
if (candidates.length === 0) return null;
const ordered = [...candidates].sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
return (candidates.find((s) => s.default) ?? ordered[0]).id;
};

const handleDrop = (columnKey: string) => {
const issue = draggingId ? issueById.get(draggingId) : null;
setDraggingId(null);
setDropKey(null);
if (!issue || !onCardMove) return;
const target = resolveTargetStateId(columnKey, issue);
if (!target || target === issue.state_id) return;
onCardMove(issue.id, target);
};

// Build the columns + the leftover "No state" bucket. In group mode columns
// are the canonical state groups present in the workspace (so a multi-project
Expand Down Expand Up @@ -106,13 +135,54 @@ export function IssueLayoutBoard({
prSummary={prSummary[issue.id]}
href={issueHref(issue.id)}
now={now}
draggable={dndEnabled}
isDragging={draggingId === issue.id}
onDragStart={() => setDraggingId(issue.id)}
onDragEnd={() => {
setDraggingId(null);
setDropKey(null);
}}
/>
);

// Whether a column accepts the in-flight card (skip its current column).
const canDropOn = (columnKey: string): boolean => {
if (!dndEnabled || !draggingId) return false;
const issue = issueById.get(draggingId);
if (!issue) return false;
const target = resolveTargetStateId(columnKey, issue);
return Boolean(target) && target !== issue.state_id;
};

return (
<div className="flex gap-3 overflow-x-auto px-4 py-4">
{columns.map((col) => (
<BoardColumn key={col.key} title={col.title} color={col.color} count={col.items.length}>
<BoardColumn
key={col.key}
title={col.title}
color={col.color}
count={col.items.length}
isDropTarget={dropKey === col.key && canDropOn(col.key)}
onDragOver={
dndEnabled
? (e) => {
if (!canDropOn(col.key)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dropKey !== col.key) setDropKey(col.key);
}
: undefined
}
onDragLeave={dndEnabled ? () => setDropKey((k) => (k === col.key ? null : k)) : undefined}
onDrop={
dndEnabled
? (e) => {
e.preventDefault();
handleDrop(col.key);
}
: undefined
}
>
{col.items.map(renderCard)}
{col.items.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-(--txt-tertiary)">No work items</p>
Expand All @@ -134,14 +204,31 @@ function BoardColumn({
color,
count,
children,
isDropTarget,
onDragOver,
onDragLeave,
onDrop,
}: {
title: string;
color?: string | null;
count: number;
children: React.ReactNode;
isDropTarget?: boolean;
onDragOver?: (e: React.DragEvent) => void;
onDragLeave?: (e: React.DragEvent) => void;
onDrop?: (e: React.DragEvent) => void;
}) {
return (
<div className="flex w-72 shrink-0 flex-col rounded-(--radius-lg) border border-(--border-subtle) bg-(--bg-layer-1)">
<div
className={`flex w-72 shrink-0 flex-col rounded-(--radius-lg) border bg-(--bg-layer-1) transition-colors ${
isDropTarget
? 'border-(--border-accent-strong) bg-(--bg-layer-1-hover)'
: 'border-(--border-subtle)'
}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div
className="flex items-center gap-2 border-b border-(--border-subtle) px-3 py-2"
style={{
Expand Down Expand Up @@ -169,6 +256,10 @@ interface BoardCardProps {
prSummary?: IssueLayoutProps['prSummary'][string];
href: string;
now: number;
draggable?: boolean;
isDragging?: boolean;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
}

function BoardCard({
Expand All @@ -181,12 +272,25 @@ function BoardCard({
prSummary,
href,
now,
draggable,
isDragging,
onDragStart,
onDragEnd,
}: BoardCardProps) {
const displayId = issueDisplayId(issue, project, projectsById);
return (
<Link
to={href}
className="block rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) p-2.5 no-underline transition-colors hover:border-(--border-strong) hover:bg-(--bg-layer-1-hover)"
draggable={draggable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', issue.id);
onDragStart?.(e);
}}
onDragEnd={onDragEnd}
className={`block rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) p-2.5 no-underline transition-colors hover:border-(--border-strong) hover:bg-(--bg-layer-1-hover) ${
draggable ? 'cursor-grab active:cursor-grabbing' : ''
} ${isDragging ? 'opacity-50' : ''}`}
>
<div className="flex items-start gap-2">
<PriorityIcon priority={issue.priority as Priority | null | undefined} />
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export interface IssueLayoutProps {
* workspace-wide views where states differ per project but share groups.
*/
groupByStateGroup?: boolean;
/**
* Board layout only: called when a card is dragged into another column to move
* the work item to that column's state. `targetStateId` is already resolved to
* a concrete state (in the card's own project when grouping by state group).
* When omitted, the board is not draggable.
*/
onCardMove?: (issueId: string, targetStateId: string) => void;
}

/** Canonical state groups, in board column order. */
Expand Down
43 changes: 42 additions & 1 deletion apps/web/src/pages/IssueListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ export function IssueListPage() {
);
// Chains reorder saves so rapid drags commit in order (see handleReorder).
const reorderChain = useRef<Promise<unknown>>(Promise.resolve());
// Per-issue serialization for board drag-to-column: chain PATCHes so rapid
// drags of the same card commit in order, and a sequence token so only the
// latest move's failure triggers a refetch.
const cardMoveChains = useRef<Map<string, Promise<void>>>(new Map());
const cardMoveSeq = useRef<Map<string, number>>(new Map());
// Latest route key, so a late drag-failure refetch can be discarded if the
// user has since navigated to a different project.
const routeKeyRef = useRef('');
useEffect(() => {
routeKeyRef.current = `${workspaceSlug ?? ''}/${projectId ?? ''}`;
}, [workspaceSlug, projectId]);
useDocumentTitle('Work items');

const refetchIssues = () => {
Expand Down Expand Up @@ -587,6 +598,36 @@ export function IssueListPage() {

const reorderEnabled = listDisplay.orderBy === 'manual';

// Board drag-to-column: optimistically move the card's state, then persist.
// PATCHes for the same card are chained (commit in order); only the latest
// move's failure refetches, so a stale older request can't clobber a newer one.
const handleCardMove = (issueId: string, targetStateId: string) => {
if (!workspaceSlug || !projectId) return;
const current = issues.find((i) => i.id === issueId);
if (!current || current.state_id === targetStateId) return;
const routeKey = `${workspaceSlug}/${projectId}`;
const seq = (cardMoveSeq.current.get(issueId) ?? 0) + 1;
cardMoveSeq.current.set(issueId, seq);
setIssues((prev) =>
prev.map((i) => (i.id === issueId ? { ...i, state_id: targetStateId } : i)),
);
const prevChain = cardMoveChains.current.get(issueId) ?? Promise.resolve();
const next = prevChain
.catch(() => {})
.then(() =>
issueService.update(workspaceSlug, projectId, issueId, { state_id: targetStateId }),
)
.then(() => undefined)
.catch(() => {
// Skip if superseded by a newer move, or if the user navigated to a
// different project (the refetch would replace the new route's list).
if (cardMoveSeq.current.get(issueId) === seq && routeKeyRef.current === routeKey) {
refetchIssues();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cardMoveChains.current.set(issueId, next);
};

return (
<div className="w-full">
<div className="flex items-center justify-between gap-4 border-b border-(--border-subtle) px-4 py-3">
Expand Down Expand Up @@ -710,7 +751,7 @@ export function IssueListPage() {
onReorder={reorderEnabled ? handleReorder : undefined}
/>
)}
{layout === 'board' && <IssueLayoutBoard {...layoutProps} />}
{layout === 'board' && <IssueLayoutBoard {...layoutProps} onCardMove={handleCardMove} />}
{layout === 'spreadsheet' && <IssueLayoutSpreadsheet {...layoutProps} />}
{layout === 'calendar' && <IssueLayoutCalendar {...layoutProps} />}
{layout === 'gantt' && <IssueLayoutGantt {...layoutProps} />}
Expand Down
43 changes: 42 additions & 1 deletion apps/web/src/pages/WorkspaceViewsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export function WorkspaceViewsPage() {
const [viewLoading, setViewLoading] = useState(false);
const viewAppliedRef = useRef(false);
const prevViewIdRef = useRef<string | undefined>(undefined);
// Per-issue serialization for kanban drag-to-column (see handleCardMove).
const cardMoveChains = useRef<Map<string, Promise<void>>>(new Map());
const cardMoveSeq = useRef<Map<string, number>>(new Map());
const [projects, setProjects] = useState<ProjectApiResponse[]>([]);
const [issues, setIssues] = useState<IssueApiResponse[]>([]);
const [states, setStates] = useState<StateApiResponse[]>([]);
Expand Down Expand Up @@ -501,6 +504,42 @@ export function WorkspaceViewsPage() {
[workspaceSlug],
);

// Kanban drag-to-column: the board resolves the target to a concrete state in
// the card's own project; persist via that project and optimistically update.
// PATCHes for the same card are chained (commit in order); only the latest
// move's failure rolls back, so a stale older request can't revert a newer one.
const handleCardMove = useCallback(
(issueId: string, targetStateId: string) => {
if (!workspaceSlug) return;
const issue = issues.find((i) => i.id === issueId);
if (!issue || issue.state_id === targetStateId) return;
const prevStateId = issue.state_id;
const seq = (cardMoveSeq.current.get(issueId) ?? 0) + 1;
cardMoveSeq.current.set(issueId, seq);
setIssues((prev) =>
prev.map((i) => (i.id === issueId ? { ...i, state_id: targetStateId } : i)),
);
const prevChain = cardMoveChains.current.get(issueId) ?? Promise.resolve();
const next = prevChain
.catch(() => {})
.then(() =>
issueService.update(workspaceSlug, issue.project_id, issueId, {
state_id: targetStateId,
}),
)
.then(() => undefined)
.catch(() => {
if (cardMoveSeq.current.get(issueId) === seq) {
setIssues((prev) =>
prev.map((i) => (i.id === issueId ? { ...i, state_id: prevStateId } : i)),
);
}
});
cardMoveChains.current.set(issueId, next);
},
[workspaceSlug, issues],
);

if (loading) {
return (
<div className="flex items-center justify-center p-8 text-sm text-(--txt-tertiary)">
Expand Down Expand Up @@ -741,7 +780,9 @@ export function WorkspaceViewsPage() {
return (
<div className="-mt-(--padding-page) -mr-(--padding-page) -mb-(--padding-page) flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-auto">
{display.layout === 'kanban' && <IssueLayoutBoard {...layoutProps} groupByStateGroup />}
{display.layout === 'kanban' && (
<IssueLayoutBoard {...layoutProps} groupByStateGroup onCardMove={handleCardMove} />
)}
{display.layout === 'calendar' && <IssueLayoutCalendar {...layoutProps} />}
{display.layout === 'gantt_chart' && <IssueLayoutGantt {...layoutProps} />}
</div>
Expand Down
Loading