From c16d1ecbac03c3e24b8dc3a2696024f448d31b07 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 06:44:35 +0200 Subject: [PATCH] feat(PBI-74): migreer sprint-board componenten naar workspace-store (Story 9 / T-881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskList: leest tasks via selectTasksForStory met useShallow; DnD via applyOptimisticMutation('sprint-task-order') + settle/rollback - SprintBacklogLeft: leest stories via selectStoriesForActiveSprint met useShallow; props 'stories' verwijderd - SprintBoardClient: leest sprintStories uit selector i.p.v. lokale state; add/remove via direct setState met manuele snapshot-rollback; reorder via applyOptimisticMutation('sprint-story-order'); assignee- change via store entity-mutation; tasksByStory en sprintStoryIdList props weg - app/(app)/.../sprint/[sprintId]/page.tsx: bouwt SprintHydrationData voor wrapper; geeft alleen non-store props door aan SprintBoardClient useSprintStore wordt nergens meer geïmporteerd — alleen comment-referentie in SprintHydrationWrapper. Cleanup van het bestand zelf in T-883. Verify groen (671 tests, typecheck, lint clean). --- .../products/[id]/sprint/[sprintId]/page.tsx | 16 -- components/sprint/sprint-backlog.tsx | 19 +- components/sprint/sprint-board-client.tsx | 214 ++++++++++-------- components/sprint/task-list.tsx | 66 ++++-- 4 files changed, 170 insertions(+), 145 deletions(-) diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index fa862ef..84ec08e 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -16,7 +16,6 @@ import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' import { parsePauseContext } from '@/lib/pause-context' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' -import type { Task } from '@/components/sprint/task-list' import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' @@ -110,19 +109,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { assignee_username: s.assignee?.username ?? null, })) - const tasksByStory: Record = {} const tasksByStoryWorkspace: Record = {} for (const story of sprintStories) { - tasksByStory[story.id] = story.tasks.map(t => ({ - id: t.id, - code: t.code, - title: t.title, - description: t.description, - priority: t.priority, - status: t.status, - story_id: t.story_id, - sprint_id: t.sprint_id, - })) tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({ id: t.id, code: t.code, @@ -176,7 +164,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { })), })) - const sprintStoryIdList = sprintStories.map(s => s.id) const isDemo = session.isDemo ?? false const closePath = `/products/${id}/sprint/${sprint.id}` @@ -237,10 +224,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { key={sprint.id} productId={id} sprintId={sprint.id} - stories={sprintStoryItems} pbisWithStories={pbisWithStories} - sprintStoryIdList={sprintStoryIdList} - tasksByStory={tasksByStory} isDemo={isDemo} currentUserId={session.userId} members={members} diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index d121269..bc3bc51 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -6,6 +6,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -20,7 +21,8 @@ import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { PRIORITY_COLORS } from '@/components/shared/priority-select' -import { useSprintStore } from '@/stores/sprint-store' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog' import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog' @@ -236,7 +238,6 @@ function SortableSprintRow({ interface SprintBacklogLeftProps { sprintId: string - stories: SprintStory[] isDemo: boolean onRemove: (storyId: string) => void onSelect: (storyId: string) => void @@ -248,19 +249,21 @@ interface SprintBacklogLeftProps { } export function SprintBacklogLeft({ - sprintId, stories, isDemo, onRemove, onSelect, selectedStoryId, + sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId, currentUserId, productId, members, onAssigneeChange, }: SprintBacklogLeftProps) { - const { sprintStoryOrder } = useSprintStore() + const orderedStories = useSprintWorkspaceStore( + useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), + ) const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' }) const [isPending, startTransition] = useTransition() const [storyDialogState, setStoryDialogState] = useState(null) - const unassignedCount = stories.filter(s => s.assignee_id === null).length + const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null function handleClaimAll() { - const unassigned = stories.filter(s => s.assignee_id === null) + const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null) unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername)) startTransition(async () => { const result = await claimAllUnassignedInActiveSprintAction(productId) @@ -273,10 +276,6 @@ export function SprintBacklogLeft({ }) } - const storyMap = Object.fromEntries(stories.map(s => [s.id, s])) - const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id) - const orderedStories = order.map(id => storyMap[id]).filter(Boolean) - return (
isDemo: boolean currentUserId: string members: ProductMember[] } +function toWorkspaceStory(story: SprintStory, sprintId: string): SprintWorkspaceStory { + return { + id: story.id, + code: story.code, + title: story.title, + description: story.description, + acceptance_criteria: story.acceptance_criteria, + priority: story.priority, + sort_order: story.sort_order, + status: story.status, + pbi_id: story.pbi_id, + sprint_id: sprintId, + created_at: story.created_at, + taskCount: story.taskCount, + doneCount: story.doneCount, + assignee_id: story.assignee_id, + assignee_username: story.assignee_username, + } +} + export function SprintBoardClient({ productId, sprintId, - stories, pbisWithStories, - sprintStoryIdList, - tasksByStory, isDemo, currentUserId, members, }: SprintBoardClientProps) { - const [sprintStories, setSprintStories] = useState(stories) - const [sprintStoryIds, setSprintStoryIds] = useState>(() => new Set(sprintStoryIdList)) + const sprintStories = useSprintWorkspaceStore( + useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), + ) + const sprintStoryIds = new Set(sprintStories.map(s => s.id)) const [selectedStoryId, setSelectedStoryId] = useState(null) - const { - sprintStoryOrder, - initSprint, - addStoryToSprint, - removeStoryFromSprint, - reorderSprintStories, - rollbackSprint, - } = useSprintStore() const [activeDragStory, setActiveDragStory] = useState(null) const [, startTransition] = useTransition() - useEffect(() => { - initSprint(sprintId, stories.map(s => s.id)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sprintId]) - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -82,9 +87,8 @@ export function SprintBoardClient({ const activeId = active.id.toString() const overId = over.id.toString() - const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id) - // Drag from left (product backlog) → add to sprint (middle) + // Drag from product backlog (left) → add to sprint (middle) if (activeId.startsWith('pb:')) { const storyId = activeId.slice(3) const droppingOnSprint = @@ -92,106 +96,119 @@ export function SprintBoardClient({ (!overId.startsWith('pb:') && overId !== 'backlog-zone') if (droppingOnSprint && !sprintStoryIds.has(storyId)) { const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (!storyData) return - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - addStoryToSprint(sprintId, storyId) - startTransition(async () => { - const result = await addStoryToSprintAction(sprintId, storyId) - if (!result.success) { - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) - toast.error(result.error ?? 'Toevoegen mislukt') - } - }) + if (storyData) handleAdd(storyId, storyData) } return } - // Drag from middle (sprint backlog) → left (product backlog) → remove + // Drag from sprint (middle) → product backlog (left) → remove if (overId === 'backlog-zone') { - const storyData = sprintStories.find(s => s.id === activeId) - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(activeId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== activeId)) - removeStoryFromSprint(sprintId, activeId) - if (selectedStoryId === activeId) setSelectedStoryId(null) - startTransition(async () => { - const result = await removeStoryFromSprintAction(activeId) - if (!result.success) { - if (storyData) { - setSprintStoryIds(prev => new Set([...prev, activeId])) - setSprintStories(prev => [...prev, storyData]) - } - addStoryToSprint(sprintId, activeId) - toast.error('Verwijderen mislukt') - } - }) + handleRemove(activeId) return } - // Reorder within sprint (middle panel) + // Reorder within sprint if (activeId !== overId && !activeId.startsWith('pb:')) { - const prevOrder = [...order] - const newOrder = order.includes(overId) - ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) - : [...order.filter(id => id !== activeId), activeId] - - reorderSprintStories(sprintId, newOrder) - startTransition(async () => { - const result = await reorderSprintStoriesAction(sprintId, newOrder) - if (!result.success) { - rollbackSprint(sprintId, prevOrder) - toast.error('Volgorde opslaan mislukt') - } - }) + handleReorder(activeId, overId) } } - function handleAdd(storyId: string) { + function handleAdd(storyId: string, storyData: SprintStory) { if (sprintStoryIds.has(storyId)) return - const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (!storyData) return - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - addStoryToSprint(sprintId, storyId) + + const store = useSprintWorkspaceStore.getState() + const prevStory = store.entities.storiesById[storyId] + const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] + + useSprintWorkspaceStore.setState((s) => { + s.entities.storiesById[storyId] = toWorkspaceStory(storyData, sprintId) + const list = s.relations.storyIdsBySprint[sprintId] ?? [] + if (!list.includes(storyId)) list.push(storyId) + s.relations.storyIdsBySprint[sprintId] = list + }) + startTransition(async () => { const result = await addStoryToSprintAction(sprintId, storyId) if (!result.success) { - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) + useSprintWorkspaceStore.setState((s) => { + if (prevStory === undefined) { + delete s.entities.storiesById[storyId] + } else { + s.entities.storiesById[storyId] = prevStory + } + s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds + }) toast.error(result.error ?? 'Toevoegen mislukt') } }) } - function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { - setSprintStories(prev => - prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s) - ) - } - function handleRemove(storyId: string) { - const storyData = sprintStories.find(s => s.id === storyId) - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) + const store = useSprintWorkspaceStore.getState() + const prevStory = store.entities.storiesById[storyId] + const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] + + useSprintWorkspaceStore.setState((s) => { + const list = s.relations.storyIdsBySprint[sprintId] + if (list) { + s.relations.storyIdsBySprint[sprintId] = list.filter((id) => id !== storyId) + } + const story = s.entities.storiesById[storyId] + if (story) story.sprint_id = null + }) + if (selectedStoryId === storyId) setSelectedStoryId(null) + startTransition(async () => { const result = await removeStoryFromSprintAction(storyId) if (!result.success) { - if (storyData) { - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - } - addStoryToSprint(sprintId, storyId) + useSprintWorkspaceStore.setState((s) => { + if (prevStory) s.entities.storiesById[storyId] = prevStory + s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds + }) toast.error('Verwijderen mislukt') } }) } - const selectedTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : [] + function handleReorder(activeId: string, overId: string) { + const store = useSprintWorkspaceStore.getState() + const order = store.relations.storyIdsBySprint[sprintId] ?? [] + const prevOrder = [...order] + const newOrder = order.includes(overId) + ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) + : [...order.filter(id => id !== activeId), activeId] + + const mutationId = store.applyOptimisticMutation({ + kind: 'sprint-story-order', + sprintId, + prevStoryIds: prevOrder, + }) + useSprintWorkspaceStore.setState((s) => { + s.relations.storyIdsBySprint[sprintId] = newOrder + }) + + startTransition(async () => { + const result = await reorderSprintStoriesAction(sprintId, newOrder) + const st = useSprintWorkspaceStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } + }) + } + + function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { + useSprintWorkspaceStore.setState((s) => { + const story = s.entities.storiesById[storyId] + if (story) { + story.assignee_id = assigneeId + story.assignee_username = assigneeUsername + } + }) + } return (
@@ -213,12 +230,14 @@ export function SprintBoardClient({ sprintStoryIds={sprintStoryIds} isDemo={isDemo} productId={productId} - onAdd={handleAdd} + onAdd={(storyId) => { + const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) + if (storyData) handleAdd(storyId, storyData) + }} />, ) : ( diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 0b078f3..1750536 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition, useEffect } from 'react' +import { useState, useTransition } from 'react' import { useRouter, usePathname } from 'next/navigation' import { DndContext, DragEndEvent, DragOverlay, @@ -13,12 +13,18 @@ import { import { CSS } from '@dnd-kit/utilities' import { Pencil } from 'lucide-react' import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' -import { useSprintStore } from '@/stores/sprint-store' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { selectTasksForStory } from '@/stores/sprint-workspace/selectors' +import type { + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { debugProps } from '@/lib/debug' @@ -48,9 +54,11 @@ const STATUS_LABELS: Record = { } +// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra +// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883). export interface Task { id: string - code: string + code: string | null title: string description: string | null priority: number @@ -59,18 +67,19 @@ export interface Task { sprint_id: string | null } +type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail + interface TaskListProps { storyId: string sprintId: string productId: string - tasks: Task[] isDemo: boolean } function SortableTaskRow({ task, code, isDemo, onStatusToggle, onEdit, }: { - task: Task + task: WorkspaceTask code: string | null isDemo: boolean onStatusToggle: () => void @@ -149,22 +158,17 @@ function SortableTaskRow({ ) } -export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { - const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() +export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) { + const orderedTasks = useSprintWorkspaceStore( + useShallow((s) => selectTasksForStory(s, storyId)), + ) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() const router = useRouter() const pathname = usePathname() - const idKey = tasks.map(t => t.id).join(',') - useEffect(() => { - initTasks(storyId, idKey ? idKey.split(',') : []) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [storyId, idKey]) - - const taskMap = Object.fromEntries(tasks.map(t => [t.id, t])) - const order = taskOrder[storyId] ?? tasks.map(t => t.id) - const orderedTasks = order.map(id => taskMap[id]).filter(Boolean) + const taskMap: Record = {} + for (const t of orderedTasks) taskMap[t.id] = t const doneCount = orderedTasks.filter(t => t.status === 'DONE').length @@ -176,17 +180,37 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (!over || active.id === over.id) return - const prevOrder = [...order] - const newOrder = arrayMove([...order], order.indexOf(active.id as string), order.indexOf(over.id as string)) - reorderTasks(storyId, newOrder) + const store = useSprintWorkspaceStore.getState() + const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])] + const newOrder = arrayMove( + [...prevOrder], + prevOrder.indexOf(active.id as string), + prevOrder.indexOf(over.id as string), + ) + + const mutationId = store.applyOptimisticMutation({ + kind: 'sprint-task-order', + storyId, + prevTaskIds: prevOrder, + }) + useSprintWorkspaceStore.setState((s) => { + s.relations.taskIdsByStory[storyId] = newOrder + }) setActiveDragId(null) + startTransition(async () => { const result = await reorderTasksAction(storyId, newOrder) - if (!result.success) { rollbackTasks(storyId, prevOrder); toast.error('Volgorde opslaan mislukt') } + const st = useSprintWorkspaceStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } }) } - function handleStatusToggle(task: Task) { + function handleStatusToggle(task: WorkspaceTask) { startTransition(async () => { await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO') })