feat(M14): 3-pane backlog — generic SplitPane, BacklogStore, SSE realtime, card-grid TaskPanel (#22)
* feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary number of panes with n-1 draggable dividers and JSON cookie persistence. Replaces TriplePane; mobile renders tabs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(split-pane): migrate callers to new panes[] API Backlog page and sprint board now use generic SplitPane. TriplePane removed; sprint board uses 3-pane with defaultSplit=[28,35,37]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(split-pane): add unit tests for 2/3-pane, cookie-restore, mobile tabs Added jsdom + @testing-library/react devDeps for component testing. 7 cases: render, divider count, cookie restore, invalid cookie fallback, mobile tab render/switch, and no-dividers-on-mobile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add BacklogStore Zustand store with applyChange reducer State: pbis, storiesByPbi, tasksByStory. setInitialData for server hydration; applyChange(entity, op, data) handles I/U/D for SSE events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): server-fetch tasks + hydrate BacklogStore on page load Page now fetches tasks parallel to stories and groups by story_id. BacklogHydrationWrapper calls setInitialData on mount so the store is ready for downstream SSE consumers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add EmptyPanel shared component, replace inline empty states EmptyPanel takes title?, message, and optional action with DemoTooltip. Replaces duplicate inline empty-state markup in pbi-list and story-panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add TaskPanel with sortable rows and TaskDialog wiring Reads selectedStoryId + tasksByStory from stores. DnD reorder via reorderTasksAction. Row click → ?editTask, + button → ?newTask&storyId. DemoTooltip on drag handles and + button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): wire TaskPanel + TaskDialog into backlog page 3-pane SplitPane [20,45,35]. searchParams for newTask/editTask. TaskDialog and EditTaskLoader render on ?newTask and ?editTask. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add TaskPanel tests for render states and click handlers 7 cases: no-story empty, no-tasks empty+action, tasks render, + button router.push, row click router.push, demo disabled button, demo disabled handles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate PbiList to store-driven via useBacklogStore Removes pbis prop; reads from useBacklogStore(s => s.pbis) so SSE updates reflect in real-time without prop drilling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate StoryPanel to store-driven + selectStory on click Removes storiesByPbi prop; reads from useBacklogStore. Card click now dispatches selectStory(id) + shows isSelected highlight. Edit moved to inline pencil button. page.tsx drops pbis/storiesByPbi props. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add 3-pane integration tests for click-cascade flow Covers: empty states, PBI→stories, story→tasks, cascade-reset, isSelected highlight. localStorage mocked for sort-mode persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1115): SSE backlog realtime — endpoint, hook, hydration mount, tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ST-1116): update functional-spec (3-pane backlog + mobile) and architecture (backlog SSE + backlog-store) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1117): TaskPanel card-grid — BacklogCard + rectSortingStrategy, tests updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): correct PbiStatusApi type and remove duplicate mock keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6cd98129f2
commit
8877ea469d
22 changed files with 2474 additions and 305 deletions
225
components/backlog/task-panel.tsx
Normal file
225
components/backlog/task-panel.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store'
|
||||
import { reorderTasksAction } from '@/actions/tasks'
|
||||
import { BacklogCard } from './backlog-card'
|
||||
import { EmptyPanel } from './empty-panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
|
||||
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||
}
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
TO_DO: 'To Do',
|
||||
IN_PROGRESS: 'Bezig',
|
||||
REVIEW: 'Review',
|
||||
DONE: 'Klaar',
|
||||
}
|
||||
|
||||
function SortableTaskCard({
|
||||
task,
|
||||
isDemo,
|
||||
onClick,
|
||||
}: {
|
||||
task: BacklogTask
|
||||
isDemo: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: task.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...(isDemo ? {} : listeners)}
|
||||
title={task.title}
|
||||
priority={task.priority}
|
||||
isDragging={isDragging}
|
||||
onClick={onClick}
|
||||
badge={
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 border',
|
||||
STATUS_COLORS[task.status] ?? STATUS_COLORS.TO_DO,
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskPanelProps {
|
||||
productId: string
|
||||
isDemo: boolean
|
||||
closePath: string
|
||||
}
|
||||
|
||||
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
||||
const router = useRouter()
|
||||
const [, startTransition] = useTransition()
|
||||
const selectedStoryId = useSelectionStore((s) => s.selectedStoryId)
|
||||
const tasksByStory = useBacklogStore((s) => s.tasksByStory)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
|
||||
|
||||
const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null
|
||||
|
||||
// Merge local order with rawTasks for optimistic reorder
|
||||
const tasks: BacklogTask[] | null = rawTasks === null
|
||||
? null
|
||||
: localOrder
|
||||
? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[]
|
||||
: rawTasks
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveDragId(event.active.id as string)
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveDragId(null)
|
||||
if (!selectedStoryId || !tasks) return
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const ids = tasks.map((t) => t.id)
|
||||
const oldIndex = ids.indexOf(active.id as string)
|
||||
const newIndex = ids.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const newOrder = arrayMove(ids, oldIndex, newIndex)
|
||||
setLocalOrder(newOrder)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reorderTasksAction(selectedStoryId, newOrder)
|
||||
if (result?.error) {
|
||||
setLocalOrder(null)
|
||||
toast.error(result.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const navActions = (
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo || !selectedStoryId}
|
||||
onClick={() => {
|
||||
if (!selectedStoryId) return
|
||||
router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`)
|
||||
}}
|
||||
>
|
||||
+ Nieuwe taak
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
)
|
||||
|
||||
if (tasks === null) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PanelNavBar title="Taken" actions={navActions} />
|
||||
<EmptyPanel message="Selecteer een story om de taken te bekijken." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PanelNavBar title="Taken" actions={navActions} />
|
||||
<EmptyPanel
|
||||
message="Nog geen taken voor deze story."
|
||||
action={{
|
||||
label: 'Nieuwe taak',
|
||||
onClick: () => router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`),
|
||||
disabled: isDemo,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PanelNavBar title="Taken" actions={navActions} />
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<DndContext
|
||||
id="task-panel"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{tasks.map((task) => (
|
||||
<SortableTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isDemo={isDemo}
|
||||
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask && (
|
||||
<BacklogCard
|
||||
title={activeTask.title}
|
||||
priority={activeTask.priority}
|
||||
className="border-primary shadow-xl opacity-90"
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue