271 lines
9.0 KiB
TypeScript
271 lines
9.0 KiB
TypeScript
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<number, Issue>()
|
|
for (const issue of todoIssues)
|
|
byNumber.set(issue.number, issue)
|
|
|
|
// Group children by parent number, preserving input order.
|
|
const childrenOf = new Map<number, Issue[]>()
|
|
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<string>()
|
|
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<string | null>(null)
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
)
|
|
|
|
const issuesByColumn = useMemo(() => {
|
|
const map: Record<IssueColumn, Issue[]> = {
|
|
'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<string, HTMLDivElement | null>())
|
|
|
|
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 (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={kanbanCollisionDetection}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={() => setActiveId(null)}
|
|
>
|
|
<div className="grid h-full grid-cols-4 gap-3 p-3">
|
|
{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 (
|
|
<KanbanColumn
|
|
key={column}
|
|
column={column}
|
|
issues={columnIssues}
|
|
onCreate={column === 'todo' ? onCreateIssue : undefined}
|
|
>
|
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
|
{ordered.map(({ issue, depth }) => {
|
|
const { locked, prerequisiteNumber } = isIssueLocked(issue, issues)
|
|
return (
|
|
<IssueCard
|
|
key={issue.id}
|
|
ref={(node) => {
|
|
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}
|
|
/>
|
|
)
|
|
})}
|
|
</SortableContext>
|
|
</KanbanColumn>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<DragOverlay>
|
|
{activeIssue ? <IssueCard issue={activeIssue} /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)
|
|
}
|