From 784791d8f9b6335b58aee155ad34784bd19f9c99 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 07:30:46 +0200 Subject: [PATCH] feat(sprint-dialogs): conform aan dialog-pattern + entity-profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 5 van PBI "Alle dialogen conform docs/patterns/dialog.md". - lib/schemas/sprint.ts — gedeelde zod-schemas (create/dates/goal) - actions/sprints.ts — code+fieldErrors voor 422; code: 403 voor auth/demo errors - StartSprintButton dialog: useDirtyCloseGuard, useDialogSubmitShortcut, entityDialog* layout-classes; DemoTooltip op trigger; veld-niveau errors via fieldErrors - SprintHeader's date- en complete-dialogen: zelfde behandeling; date- dialog krijgt dirty-guard, complete-dialog krijgt DemoTooltip op bevestigen - docs/specs/dialogs/sprint.md — entity-profile dat alle drie de modes documenteert; consolidatie naar één SprintDialog component bewust uitgesteld - Sprint-dates tests aangepast aan nieuwe action-shape Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/sprint-dates.test.ts | 14 +-- actions/sprints.ts | 81 ++++++++------ components/sprint/sprint-header.tsx | 129 ++++++++++++++-------- components/sprint/start-sprint-button.tsx | 110 ++++++++++++------ docs/INDEX.md | 1 + docs/specs/dialogs/sprint.md | 72 ++++++++++++ lib/schemas/sprint.ts | 38 +++++++ 7 files changed, 320 insertions(+), 125 deletions(-) create mode 100644 docs/specs/dialogs/sprint.md create mode 100644 lib/schemas/sprint.ts diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index 6cb59c2..eaa05db 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -53,10 +53,9 @@ describe('createSprintAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) - const result = await createSprintAction(undefined, fd) - expect(result.error).toBeTruthy() - const errors = result.error as Record - expect(errors.end_date?.[0]).toContain('Einddatum') + const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record } + expect(result.code).toBe(422) + expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') }) it('accepts no dates (both optional)', async () => { @@ -81,10 +80,9 @@ describe('updateSprintDatesAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) - const result = await updateSprintDatesAction(undefined, fd) - expect(result.error).toBeTruthy() - const errors = result.error as Record - expect(errors.end_date?.[0]).toContain('Einddatum') + const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record } + expect(result.code).toBe(422) + expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') }) it('blocks demo users', async () => { diff --git a/actions/sprints.ts b/actions/sprints.ts index 8eb2292..60f109c 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -3,10 +3,14 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' -import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' +import { + createSprintSchema, + updateSprintDatesSchema, + updateSprintGoalSchema, +} from '@/lib/schemas/sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -16,39 +20,34 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } -const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) - -function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) { - if (data.start_date && data.end_date && data.end_date < data.start_date) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) - } -} +type SprintFieldErrors = Record export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const parsed = z.object({ - productId: z.string(), - sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), - start_date: dateField, - end_date: dateField, - }).superRefine(validateDateOrder).safeParse({ + const parsed = createSprintSchema.safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden' } + if (!product) return { error: 'Product niet gevonden', code: 403 } const existing = await prisma.sprint.findFirst({ where: { product_id: parsed.data.productId, status: 'ACTIVE' }, }) - if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id } + if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } const sprint = await prisma.sprint.create({ data: { @@ -66,24 +65,26 @@ export async function createSprintAction(_prevState: unknown, formData: FormData export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const parsed = z.object({ - id: z.string(), - start_date: dateField, - end_date: dateField, - }).superRefine(validateDateOrder).safeParse({ + const parsed = updateSprintDatesSchema.safeParse({ id: formData.get('id'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden' } + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } await prisma.sprint.update({ where: { id: parsed.data.id }, @@ -95,19 +96,27 @@ export async function updateSprintDatesAction(_prevState: unknown, formData: For export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - const id = formData.get('id') as string - const sprint_goal = formData.get('sprint_goal') as string - if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' } + const parsed = updateSprintGoalSchema.safeParse({ + id: formData.get('id'), + sprint_goal: formData.get('sprint_goal'), + }) + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, + } + } const sprint = await prisma.sprint.findFirst({ - where: { id, product: productAccessFilter(session.userId) }, + where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden' } + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } - await prisma.sprint.update({ where: { id }, data: { sprint_goal } }) + await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index b47a567..3668466 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -1,17 +1,25 @@ 'use client' -import { useState, useTransition, useActionState } from 'react' -import { useFormStatus } from 'react-dom' +import { useState, useTransition, useActionState, useRef } from 'react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, - DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { toast } from 'sonner' import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { + useDirtyCloseGuard, + DirtyCloseGuardDialog, +} from '@/components/shared/use-dirty-close-guard' +import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' +import { + entityDialogContentClasses, + entityDialogFooterClasses, + entityDialogHeaderClasses, +} from '@/components/shared/entity-dialog-layout' import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints' import type { SprintStory } from './sprint-backlog' @@ -31,9 +39,11 @@ interface SprintHeaderProps { sprintStories: SprintStory[] } -function SaveGoalButton() { - const { pending } = useFormStatus() - return +interface ActionResult { + success?: boolean + error?: string + code?: number + fieldErrors?: Record } function toDateInputValue(d: Date | null) { @@ -47,33 +57,39 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem const [completeOpen, setCompleteOpen] = useState(false) const [decisions, setDecisions] = useState>({}) const [isCompleting, startCompleting] = useTransition() + const [datesDirty, setDatesDirty] = useState(false) + const datesFormRef = useRef(null) - const [, goalFormAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await updateSprintGoalAction(_prev, fd) + const [, goalFormAction, goalPending] = useActionState( + async (_prev, fd) => { + const result = await updateSprintGoalAction(_prev, fd) as ActionResult if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') } - else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') + else if (result?.error) toast.error(result.error) return result }, - undefined + undefined, ) - const [datesState, datesFormAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await updateSprintDatesAction(_prev, fd) - if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') } - else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') + const [datesState, datesFormAction, datesPending] = useActionState( + async (_prev, fd) => { + const result = await updateSprintDatesAction(_prev, fd) as ActionResult + if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') } + else if (result?.code !== 422 && result?.error) toast.error(result.error) return result }, - undefined + undefined, ) + const datesFieldError = (field: string) => datesState?.fieldErrors?.[field]?.[0] + + const datesCloseGuard = useDirtyCloseGuard(datesDirty, () => setEditingDates(false)) + const datesKeyDown = useDialogSubmitShortcut(() => datesFormRef.current?.requestSubmit()) + function setDecision(storyId: string, value: 'DONE' | 'OPEN') { setDecisions(prev => ({ ...prev, [storyId]: value })) } function handleComplete() { - // Default: stories without explicit decision → OPEN const finalDecisions: Record = {} sprintStories.forEach(s => { finalDecisions[s.id] = decisions[s.id] ?? 'OPEN' @@ -101,7 +117,9 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem