diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx index 9e7ab98..3e79093 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { IssuePRBadge } from '../IssuePRBadge'; import { @@ -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(null); + const [dropKey, setDropKey] = useState(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 @@ -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 (
{columns.map((col) => ( - + { + 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 && (

No work items

@@ -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 ( -
+
void; + onDragEnd?: (e: React.DragEvent) => void; } function BoardCard({ @@ -181,12 +272,25 @@ function BoardCard({ prSummary, href, now, + draggable, + isDragging, + onDragStart, + onDragEnd, }: BoardCardProps) { const displayId = issueDisplayId(issue, project, projectsById); return ( { + 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' : ''}`} >
diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts index 84aaa8d..1323bf9 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts +++ b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts @@ -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. */ diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index f27ad9e..015348a 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -112,6 +112,17 @@ export function IssueListPage() { ); // Chains reorder saves so rapid drags commit in order (see handleReorder). const reorderChain = useRef>(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>>(new Map()); + const cardMoveSeq = useRef>(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 = () => { @@ -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(); + } + }); + cardMoveChains.current.set(issueId, next); + }; + return (
@@ -710,7 +751,7 @@ export function IssueListPage() { onReorder={reorderEnabled ? handleReorder : undefined} /> )} - {layout === 'board' && } + {layout === 'board' && } {layout === 'spreadsheet' && } {layout === 'calendar' && } {layout === 'gantt' && } diff --git a/apps/web/src/pages/WorkspaceViewsPage.tsx b/apps/web/src/pages/WorkspaceViewsPage.tsx index 4de9bec..eca0e74 100644 --- a/apps/web/src/pages/WorkspaceViewsPage.tsx +++ b/apps/web/src/pages/WorkspaceViewsPage.tsx @@ -161,6 +161,9 @@ export function WorkspaceViewsPage() { const [viewLoading, setViewLoading] = useState(false); const viewAppliedRef = useRef(false); const prevViewIdRef = useRef(undefined); + // Per-issue serialization for kanban drag-to-column (see handleCardMove). + const cardMoveChains = useRef>>(new Map()); + const cardMoveSeq = useRef>(new Map()); const [projects, setProjects] = useState([]); const [issues, setIssues] = useState([]); const [states, setStates] = useState([]); @@ -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 (
@@ -741,7 +780,9 @@ export function WorkspaceViewsPage() { return (
- {display.layout === 'kanban' && } + {display.layout === 'kanban' && ( + + )} {display.layout === 'calendar' && } {display.layout === 'gantt_chart' && }