From 0e97590f077e00f7588dd12c80ec8549952bdf12 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 08:18:49 +0200 Subject: [PATCH] feat(PBI-75): sprint task-edit client-side via workspace-store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klik op een taak in het sprint-scherm opent de edit-dialog nu client-side via setActiveTask op de sprint-workspace-store. Geen URL-navigatie, geen volledige server re-render — alleen GET /api/tasks/{id} voor het detail. SSE propageert server-saves automatisch terug. - TaskDialog: optionele onClose/onSaved callbacks (closePath optional gemaakt — backwards compatible) - SprintTaskDialogMount: nieuwe client-component die selectActiveTask consumeert en TaskDialog rendert - SprintUrlTaskSync: deeplink (?editTask=) → store - Sprint page: mounts toegevoegd, editTask searchParam + EditTaskLoader-Suspense verwijderd - TaskList.openEditDialog roept setActiveTask aan ipv router.push - Vitest integratie-test voor SprintTaskDialogMount Out-of-scope (follow-up PBIs): newTask-flow, mobile, product-backlog. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sprint/sprint-task-dialog-mount.test.tsx | 119 ++++++++++++++++ .../products/[id]/sprint/[sprintId]/page.tsx | 22 +-- app/_components/tasks/task-dialog.tsx | 18 ++- .../sprint/sprint-task-dialog-mount.tsx | 41 ++++++ components/sprint/sprint-url-task-sync.tsx | 29 ++++ components/sprint/task-list.tsx | 2 +- docs/INDEX.md | 1 + docs/plans/PBI-75-sprint-task-edit-store.md | 128 ++++++++++++++++++ 8 files changed, 335 insertions(+), 25 deletions(-) create mode 100644 __tests__/components/sprint/sprint-task-dialog-mount.test.tsx create mode 100644 components/sprint/sprint-task-dialog-mount.tsx create mode 100644 components/sprint/sprint-url-task-sync.tsx create mode 100644 docs/plans/PBI-75-sprint-task-edit-store.md diff --git a/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx b/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx new file mode 100644 index 0000000..886dbfe --- /dev/null +++ b/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/actions/tasks', () => ({ + saveTask: vi.fn(), + deleteTask: vi.fn(), +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types' + +const TASK_DETAIL: SprintWorkspaceTaskDetail = { + id: 't1', + code: 'T-1', + title: 'Mijn taak', + description: 'Beschrijving', + priority: 2, + sort_order: 1, + status: 'in_progress', + story_id: 'story-1', + sprint_id: 'sprint-1', + created_at: new Date('2026-01-15'), + _detail: true, + implementation_plan: 'Stap 1\nStap 2', +} + +function resetStore() { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.sprintsById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.sprintIdsByProduct = {} + s.relations.storyIdsBySprint = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductSprintsIds = {} + s.loading.loadingProductId = null + s.loading.loadedSprintIds = {} + s.loading.loadingSprintId = null + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + s.loading.activeRequestId = null + s.pendingMutations = {} + }) +} + +beforeEach(() => { + resetStore() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('SprintTaskDialogMount', () => { + it('rendert niets wanneer er geen active task is', () => { + const { container } = render( + , + ) + expect(container.textContent).toBe('') + }) + + it('rendert niets wanneer active task geen _detail heeft', () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = { + id: 't1', + code: 'T-1', + title: 'Mijn taak', + description: null, + priority: 2, + sort_order: 1, + status: 'todo', + story_id: 'story-1', + sprint_id: 'sprint-1', + created_at: new Date(), + } + s.context.activeTaskId = 't1' + }) + + const { container } = render( + , + ) + expect(container.textContent).toBe('') + }) + + it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = TASK_DETAIL + s.context.activeTaskId = 't1' + }) + + render() + + expect(screen.getByText('Taak bewerken')).toBeTruthy() + expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak') + }) + + it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = TASK_DETAIL + s.context.activeTaskId = 't1' + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Annuleren' })) + + await waitFor(() => { + expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull() + }) + }) +}) diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index 84ec08e..4306219 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' @@ -9,6 +8,8 @@ import { SprintHydrationWrapper, type SprintHydrationData, } from '@/components/sprint/sprint-hydration-wrapper' +import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' +import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync' import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' import { SprintSwitcher } from '@/components/shared/sprint-switcher' import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' @@ -18,8 +19,6 @@ import { parsePauseContext } from '@/lib/pause-context' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' 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' -import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import Link from 'next/link' interface Props { @@ -27,13 +26,12 @@ interface Props { searchParams: Promise<{ newTask?: string storyId?: string - editTask?: string }> } export default async function SprintBoardPage({ params, searchParams }: Props) { const { id, sprintId } = await params - const { newTask, storyId: storyIdParam, editTask } = await searchParams + const { newTask, storyId: storyIdParam } = await searchParams const session = await getSession() if (!session.userId) redirect('/login') @@ -229,6 +227,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { currentUserId={session.userId} members={members} /> + + @@ -246,18 +246,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { isDemo={isDemo} /> )} - - {editTask && !newTask && ( - }> - - - )} ) } diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 3431cda..638d834 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -60,7 +60,9 @@ interface TaskDialogProps { task?: TaskDialogTask storyId?: string productId: string - closePath: string + closePath?: string + onClose?: () => void + onSaved?: (taskId: string) => void isDemo?: boolean } @@ -81,7 +83,7 @@ const textareaClass = cn( 'overflow-y-auto', ) -export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { +export function TaskDialog({ task, storyId, productId, closePath, onClose, onSaved, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() const [confirmDelete, setConfirmDelete] = useState(false) @@ -100,11 +102,12 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }, }) - function handleClose() { - router.push(closePath) + function close() { + if (onClose) { onClose(); return } + if (closePath) router.push(closePath) } - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close) const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { @@ -117,7 +120,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false if (result.ok) { toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') - router.push(closePath) + onSaved?.(result.task.id) + close() return } @@ -152,7 +156,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false const result = await deleteTask(task.id, { productId }) if (result.ok) { toast.success('Taak verwijderd') - router.push(closePath) + close() return } if (result.code === 403) { diff --git a/components/sprint/sprint-task-dialog-mount.tsx b/components/sprint/sprint-task-dialog-mount.tsx new file mode 100644 index 0000000..1ca0f45 --- /dev/null +++ b/components/sprint/sprint-task-dialog-mount.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { selectActiveTask } from '@/stores/sprint-workspace/selectors' +import { isDetail } from '@/stores/sprint-workspace/types' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { taskStatusFromApi } from '@/lib/task-status' +import type { TaskStatus } from '@prisma/client' + +interface Props { + productId: string + isDemo: boolean +} + +export function SprintTaskDialogMount({ productId, isDemo }: Props) { + const task = useSprintWorkspaceStore(selectActiveTask) + const setActiveTask = useSprintWorkspaceStore((s) => s.setActiveTask) + + if (!task || !isDetail(task)) return null + + const status = (taskStatusFromApi(String(task.status)) ?? 'TO_DO') as TaskStatus + const createdAt = task.created_at instanceof Date ? task.created_at : new Date(task.created_at) + + return ( + setActiveTask(null)} + isDemo={isDemo} + /> + ) +} diff --git a/components/sprint/sprint-url-task-sync.tsx b/components/sprint/sprint-url-task-sync.tsx new file mode 100644 index 0000000..893b0af --- /dev/null +++ b/components/sprint/sprint-url-task-sync.tsx @@ -0,0 +1,29 @@ +'use client' + +// PBI-75: URL-deeplink → store sync voor sprint task-edit. +// +// Patroon spiegelt components/backlog/url-task-sync.tsx: zodra de route +// `?editTask=` draagt, schrijven we de taak-hint en roepen we +// setActiveTask aan op de sprint-workspace-store. De dialog wordt +// vervolgens client-side gemount door SprintTaskDialogMount. + +import { useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { writeTaskHint } from '@/stores/sprint-workspace/restore' + +export function SprintUrlTaskSync() { + const searchParams = useSearchParams() + const editTask = searchParams.get('editTask') + + useEffect(() => { + if (!editTask) return + const sprintId = useSprintWorkspaceStore.getState().context.activeSprintId + if (sprintId) { + writeTaskHint(sprintId, editTask) + } + useSprintWorkspaceStore.getState().setActiveTask(editTask) + }, [editTask]) + + return null +} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index d95ed60..c4ea4a3 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -223,7 +223,7 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: } function openEditDialog(taskId: string) { - router.push(`${pathname}?editTask=${taskId}`) + useSprintWorkspaceStore.getState().setActiveTask(taskId) } return ( diff --git a/docs/INDEX.md b/docs/INDEX.md index 3848845..ef5e696 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -53,6 +53,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — | | [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | +| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | diff --git a/docs/plans/PBI-75-sprint-task-edit-store.md b/docs/plans/PBI-75-sprint-task-edit-store.md new file mode 100644 index 0000000..b122553 --- /dev/null +++ b/docs/plans/PBI-75-sprint-task-edit-store.md @@ -0,0 +1,128 @@ +# PBI-75 — Sprint task-edit client-side via workspace-store + +## Context + +In het Sprint-scherm (`/products//sprint/`) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept `router.push(?editTask=)` aan vanuit [`components/sprint/task-list.tsx:226`](../../components/sprint/task-list.tsx). Dat triggert: + +- **Volledige server re-render** van [`app/(app)/products/[id]/sprint/[sprintId]/page.tsx`](../../app/(app)/products/[id]/sprint/[sprintId]/page.tsx) met zware queries (sprint, alle sprintStories+tasks, productMembers, alle PBIs+stories voor het backlog-paneel) +- **Tweede Prisma-query** in [`EditTaskLoader`](../../app/_components/tasks/edit-task-loader.tsx) voor task-detail (incl. `implementation_plan`) +- **Na save**: `router.push(closePath)` + `revalidatePath` → opnieuw alle queries + +De [`sprint-workspace-store`](../../stores/sprint-workspace/store.ts) (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow: + +- [`setActiveTask(taskId)`](../../stores/sprint-workspace/store.ts) (regel 337) — zet `context.activeTaskId` + roept `ensureTaskLoaded()` aan +- [`ensureTaskLoaded(taskId)`](../../stores/sprint-workspace/store.ts) (regel 414) — `GET /api/tasks/{id}` → upsert met `_detail: true` +- [`selectActiveTask`](../../stores/sprint-workspace/selectors.ts) (regel 91) — bestaat, nog geen consumer +- [`applyTaskEvent`](../../stores/sprint-workspace/store.ts) (regel 748) — SSE-events propageren idempotent na server-save +- Optimistic-mutation primitives (`applyOptimisticMutation` / `settleMutation` / `rollbackMutation`) + +Het patroon "URL-param → store" bestaat al voor product-workspace in [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) — we volgen dat als precedent. + +**Doel**: klik op een taak opent de edit-dialoog client-side via store-state binnen ~100ms. Geen URL-navigatie, geen server re-render, alleen `GET /api/tasks/{id}` voor het detail. + +## Aanpak + +**Architectuur**: store-mounted dialog + URL-sync component voor deeplinks. + +1. **Klik-flow**: `TaskList.openEditDialog` roept `setActiveTask(taskId)` aan op de store. Geen `router.push`. +2. **Render-flow**: nieuwe client-component `SprintTaskDialogMount` zit binnen `SprintHydrationWrapper`, subscribet `selectActiveTask`, en rendert `` zodra de active task `_detail === true` is. +3. **Save-flow**: `TaskDialog` krijgt optionele `onClose`/`onSaved` callbacks (backwards compatible met bestaande `closePath`). Mount geeft `onClose = () => setActiveTask(null)`. Server action `saveTask` blijft `revalidatePath` doen voor server-cache; SSE-event update store via `applyTaskEvent`. +4. **Deeplink-flow**: nieuwe `SprintUrlTaskSync` leest `?editTask=` en roept `setActiveTask(id)` aan (analoog aan `url-task-sync.tsx`). + +## Bestanden + wijzigingen + +### Nieuw — `components/sprint/sprint-task-dialog-mount.tsx` +Client component. Subscribet `selectActiveTask` (single-value, geen `useShallow`). Wanneer task aanwezig is en `isDetail(task)` true, mappt naar `TaskDialogTask`-shape: +- `status`: via `taskStatusFromApi` uit [`lib/task-status.ts`](../../lib/task-status.ts) (lowercase API → Prisma UPPER_SNAKE) +- `implementation_plan: task.implementation_plan ?? null` +- `created_at: new Date(task.created_at)` + +Rendert ` setActiveTask(null)} isDemo={isDemo} />`. Geen render tussen `setActiveTask` en `_detail: true` (detail-fetch <100ms). + +### Nieuw — `components/sprint/sprint-url-task-sync.tsx` +Kopie van [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) maar tegen `useSprintWorkspaceStore` en `writeTaskHint` uit [`stores/sprint-workspace/restore`](../../stores/sprint-workspace/restore.ts). + +### Wijziging — `components/sprint/task-list.tsx` (regels 225-227) +Vervang: +```ts +function openEditDialog(taskId: string) { + router.push(`${pathname}?editTask=${taskId}`) +} +``` +door: +```ts +function openEditDialog(taskId: string) { + useSprintWorkspaceStore.getState().setActiveTask(taskId) +} +``` +`openCreateDialog` (regel 222) blijft URL-gebaseerd — out-of-scope. + +### Wijziging — `app/(app)/products/[id]/sprint/[sprintId]/page.tsx` +- Verwijder `editTask` uit searchParams-destructuring (regel 36) +- Verwijder `editTask &&`-block met `` (regels 250-260) +- Verwijder ongebruikte imports (`EditTaskLoader`, `TaskDialogSkeleton`, evt. `Suspense`) +- Mount binnen `SprintHydrationWrapper`: + ```tsx + + + + + + ``` +- `newTask`-block (regels 241-248) blijft ongemoeid — out-of-scope. + +### Wijziging — `app/_components/tasks/task-dialog.tsx` +Maak `closePath` optioneel + voeg `onClose`/`onSaved` toe (backwards compatible): +```ts +interface TaskDialogProps { + task?: TaskDialogTask + storyId?: string + productId: string + closePath?: string + onClose?: () => void + onSaved?: (taskId: string) => void + isDemo?: boolean +} +``` +Refactor de drie `router.push(closePath)`-calls (regels 104, 120, 155) naar één helper: +```ts +function close() { + if (onClose) { onClose(); return } + if (closePath) router.push(closePath) +} +``` +Bestaande callers (`EditTaskLoader`, mobile, product-page, sprint `newTask`-block) blijven werken via `closePath`. Nieuwe `SprintTaskDialogMount` gebruikt `onClose`. + +### Geen wijziging +- `stores/sprint-workspace/selectors.ts` — `selectActiveTask` bestaat al +- `app/_components/tasks/edit-task-loader.tsx` — nog gebruikt door product-page en mobile + +## Edge cases + +- **Status-enum mapping**: store API-lowercase → Prisma UPPER_SNAKE via `taskStatusFromApi`, fallback `'TO_DO'` +- **`_detail: true` race**: mount rendert pas wanneer `isDetail(task)` true is — geen flash met undefined velden +- **Demo-mode**: prop blijft via server doorlopen, dialog respecteert al `isDemo` +- **Dirty-close-guard**: ingebouwd in dialog (regels 107, 172) — werkt via `onClose` +- **SSE na save**: `applyTaskEvent` updatet store automatisch +- **Deeplink + task niet bestaat**: `GET /api/tasks/{id}` 404 → store doet niets, dialog opent niet (huidige `redirect()` verdwijnt — acceptabel) + +## Verificatie + +1. **Browser** (`npm run dev`): klik op task in takenlijst → dialog opent <100ms, geen URL-verandering, alleen `GET /api/tasks/` in Network +2. **Save**: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE +3. **Deeplink**: `?editTask=` → dialog opent via `SprintUrlTaskSync` +4. **Bestaande flows ongebroken**: product-page edit, mobile edit, sprint `?newTask=1` +5. **`npm run verify && npm run build`** +6. **Vitest**: `__tests__/components/sprint/sprint-task-dialog-mount.test.tsx` — hydreer store, mock fetch, `setActiveTask(id)`, assert UPPER_SNAKE status + `onClose` clear + +## Risico's + +- Andere mounts (mobile, product-backlog, sprint `newTask`) blijven URL-gebaseerd — `closePath?` optional houdt ze werkend +- Geen `redirect()` bij not-found-deeplink (klein UX-verschil) +- SSE-latency 100-500ms na save — eventueel later mitigeren via `applyOptimisticMutation` in `onSaved`-callback + +## Out-of-scope (follow-up PBIs) + +- `?newTask=1`-flow naar store +- Mobile + product-backlog mounts +- `EditTaskLoader` verwijderen wanneer alle callers over zijn