import type { CollisionDetection, DragEndEvent, DragStartEvent } from '@dnd-kit/core' import type { Issue, IssueColumn } from '../types' import { closestCenter, DndContext, DragOverlay, KeyboardSensor, PointerSensor, pointerWithin, useSensor, useSensors, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { useEffect, useMemo, useRef, useState } from 'react' import { isIssueLocked } from '../lib/dependencies' import { compareIssuesInColumn } from '../lib/issueSort' import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag' import { COLUMN_ORDER } from '../types' import { IssueCard } from './IssueCard' import { KanbanColumn } from './KanbanColumn' interface KanbanBoardProps { issues: Issue[] onIssuesChange: (next: Issue[]) => void /** Renders the + button on the todo column when provided. */ onCreateIssue?: () => void selectedId?: string | null onSelectIssue?: (id: string | null) => void onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void } const kanbanCollisionDetection: CollisionDetection = (args) => { const pointerHits = pointerWithin(args) if (pointerHits.length > 0) { const cardHits = pointerHits.filter(c => !isColumnId(String(c.id))) return cardHits.length > 0 ? cardHits : pointerHits } return closestCenter(args) } /** * Organize todo-column issues into a forest ordered by prerequisite chains. * * Roots are issues whose `prerequisite` is empty or points outside the todo * column (cross-column pointers don't form parent/child here — they only * matter for locking in a later step). Children are issues whose * `prerequisite` points to another issue in the same todo column. * * Order: pre-order DFS. Root relative order follows the input array; * sibling order under each parent also follows input order. A visited Set * guards against pathological cycles (one issue can only have one parent, * so this is a forest in normal cases). */ function buildTodoTree(todoIssues: Issue[]): Array<{ issue: Issue, depth: number }> { const byNumber = new Map() for (const issue of todoIssues) byNumber.set(issue.number, issue) // Group children by parent number, preserving input order. const childrenOf = new Map() const roots: Issue[] = [] for (const issue of todoIssues) { const parentNum = issue.prerequisite if (parentNum != null && byNumber.has(parentNum)) { const arr = childrenOf.get(parentNum) ?? [] arr.push(issue) childrenOf.set(parentNum, arr) } else { roots.push(issue) } } const out: Array<{ issue: Issue, depth: number }> = [] const visited = new Set() function visit(issue: Issue, depth: number): void { if (visited.has(issue.id)) return visited.add(issue.id) out.push({ issue, depth }) const kids = childrenOf.get(issue.number) if (!kids) return for (const child of kids) visit(child, depth + 1) } for (const root of roots) visit(root, 0) return out } export function KanbanBoard({ issues, onIssuesChange, onCreateIssue, selectedId, onSelectIssue, onColumnChange, onDependencySet, onDependencyClear, }: KanbanBoardProps) { const [activeId, setActiveId] = useState(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) const issuesByColumn = useMemo(() => { const map: Record = { 'todo': [], 'in-progress': [], 'review': [], 'done': [], } for (const issue of issues) map[issue.column].push(issue) // 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number // 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按 // 输入顺序 push 根节点,所以可见根节点也跟着 number 降序。 for (const col of Object.keys(map) as IssueColumn[]) map[col].sort((a, b) => compareIssuesInColumn(col, a, b)) return map }, [issues]) const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null // Map of issue id -> DOM node, used so keyboard navigation can scrollIntoView. const cardRefs = useRef(new Map()) useEffect(() => { if (!selectedId) return const node = cardRefs.current.get(selectedId) node?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [selectedId]) function handleDragStart(event: DragStartEvent): void { setActiveId(String(event.active.id)) } function handleDragEnd(event: DragEndEvent): void { const { active, over } = event setActiveId(null) if (!over) return const result = resolveKanbanDrag({ issues, activeId: String(active.id), overId: String(over.id), }) if (result.type === 'none') return if (result.type === 'reorder') { onIssuesChange(arrayMove(issues, result.fromIndex, result.toIndex)) return } if (result.type === 'column') { const idx = issues.findIndex(i => i.number === result.issueNumber) if (idx < 0) return const item = issues[idx] const next = [...issues] next.splice(idx, 1) const overIdx = isColumnId(String(over.id)) ? -1 : next.findIndex(i => i.id === String(over.id)) const insertAt = overIdx < 0 ? next.length : overIdx next.splice(insertAt, 0, { ...item, column: result.toColumn }) onIssuesChange(next) onColumnChange?.(result.issueNumber, result.toColumn, result.source, result.externalId) return } if (result.type === 'dependency-set') { const idx = issues.findIndex(i => i.number === result.issueNumber) if (idx < 0) return const item = issues[idx] const next = [...issues] next.splice(idx, 1) const overIdx = next.findIndex(i => i.number === result.prerequisiteNumber) const insertAt = overIdx < 0 ? next.length : overIdx + 1 next.splice(insertAt, 0, { ...item, prerequisite: result.prerequisiteNumber }) onIssuesChange(next) onDependencySet?.(result.issueNumber, result.prerequisiteNumber) return } const idx = issues.findIndex(i => i.number === result.issueNumber) if (idx < 0) return const item = issues[idx] const next = [...issues] next.splice(idx, 1) next.push({ ...item, prerequisite: undefined }) onIssuesChange(next) onDependencyClear?.(result.issueNumber, result.prerequisiteNumber) } return ( setActiveId(null)} >
{COLUMN_ORDER.map((column) => { const columnIssues = issuesByColumn[column] // Todo column renders as a tree (prerequisite chains -> indented // children). Other columns stay flat. Keep `ids` aligned with the // visual order so dnd-kit keyboard nav / sortable indices match. const ordered: Array<{ issue: Issue, depth: number }> = column === 'todo' ? buildTodoTree(columnIssues) : columnIssues.map(issue => ({ issue, depth: 0 })) const ids = ordered.map(o => o.issue.id) return ( {ordered.map(({ issue, depth }) => { const { locked, prerequisiteNumber } = isIssueLocked(issue, issues) return ( { if (node) cardRefs.current.set(issue.id, node) else cardRefs.current.delete(issue.id) }} issue={issue} depth={depth} selected={issue.id === selectedId} onSelect={onSelectIssue} locked={locked} lockedPrerequisite={prerequisiteNumber} /> ) })} ) })}
{activeIssue ? : null}
) }