feat(sprint-dialogs): conform aan dialog-pattern + entity-profile

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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:30:46 +02:00
parent 01e77fc560
commit 784791d8f9
7 changed files with 320 additions and 125 deletions

View file

@ -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 <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
}
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<Record<string, 'DONE' | 'OPEN'>>({})
const [isCompleting, startCompleting] = useTransition()
const [datesDirty, setDatesDirty] = useState(false)
const datesFormRef = useRef<HTMLFormElement>(null)
const [, goalFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintGoalAction(_prev, fd)
const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>(
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<ActionResult | undefined, FormData>(
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<string, 'DONE' | 'OPEN'> = {}
sprintStories.forEach(s => {
finalDecisions[s.id] = decisions[s.id] ?? 'OPEN'
@ -101,7 +117,9 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
<input type="hidden" name="id" value={sprint.id} />
<Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus />
<div className="flex flex-col gap-1">
<SaveGoalButton />
<Button type="submit" size="sm" disabled={goalPending}>
{goalPending ? 'Opslaan…' : 'Opslaan'}
</Button>
<Button type="button" size="sm" variant="ghost" aria-label="Annuleer bewerken" onClick={() => setEditingGoal(false)}>×</Button>
</div>
</form>
@ -131,51 +149,66 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
{/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Sprint datums instellen</DialogTitle>
</DialogHeader>
<form action={datesFormAction} className="space-y-4 p-1">
<Dialog open={editingDates} onOpenChange={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={datesKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint datums instellen</DialogTitle>
</div>
<form
ref={datesFormRef}
id="sprint-dates-form"
action={datesFormAction}
onChange={() => setDatesDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="id" value={sprint.id} />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
{datesFieldError('start_date') && (
<p className="text-xs text-error">{datesFieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
{datesFieldError('end_date') && (
<p className="text-xs text-error">{datesFieldError('end_date')}</p>
)}
</div>
</div>
{typeof datesState?.error === 'string' && (
<p className="text-xs text-error">{datesState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
<Button type="submit">Opslaan</Button>
</div>
</form>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={datesCloseGuard.attemptClose} disabled={datesPending}>
Annuleren
</Button>
<Button type="submit" form="sprint-dates-form" disabled={datesPending}>
{datesPending ? '…' : 'Opslaan'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={datesCloseGuard} />
{/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Sprint afronden</DialogTitle>
</DialogHeader>
<div className="space-y-4 p-1">
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<p className="text-sm text-muted-foreground">
Geef per story aan wat er mee moet gebeuren:
</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="space-y-2">
{sprintStories.map(story => (
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
@ -197,11 +230,17 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
))}
</div>
</div>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button>
<Button disabled={isCompleting} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
<Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button disabled={isCompleting || isDemo} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
</Button>
</DemoTooltip>
</div>
</div>
</DialogContent>

View file

@ -1,62 +1,92 @@
'use client'
import { useState, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useState, useActionState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
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 { createSprintAction } from '@/actions/sprints'
interface StartSprintButtonProps {
productId: string
isDemo?: boolean
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
)
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
sprintId?: string
}
export function StartSprintButton({ productId }: StartSprintButtonProps) {
export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
const [open, setOpen] = useState(false)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter()
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createSprintAction(_prev, fd)
if (result.success) {
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await createSprintAction(_prev, fd) as ActionResult
if (result?.success) {
setOpen(false)
setDirty(false)
router.push(`/products/${productId}/sprint`)
} else if (result?.code !== 422 && result?.error) {
// Toast handled by caller; here we just keep the form open
}
return result
},
undefined
undefined,
)
const globalError = typeof state?.error === 'string' ? state.error : undefined
const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0]
const globalError = state?.code !== 422 ? state?.error : undefined
const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false))
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
Sprint starten
</Button>
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo}>
Sprint starten
</Button>
</DemoTooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Nieuwe Sprint starten</DialogTitle>
</DialogHeader>
<Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle>
</div>
<form action={formAction} className="space-y-4 p-1">
<form
ref={formRef}
id="start-sprint-form"
action={formAction}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="productId" value={productId} />
<div className="space-y-1.5">
@ -69,9 +99,11 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
aria-invalid={!!fieldError('sprint_goal')}
className={fieldError('sprint_goal') ? 'border-error' : ''}
/>
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).sprint_goal && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).sprint_goal[0]}</p>
{fieldError('sprint_goal') && (
<p className="text-xs text-error">{fieldError('sprint_goal')}</p>
)}
</div>
@ -79,15 +111,15 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
{fieldError('start_date') && (
<p className="text-xs text-error">{fieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
{fieldError('end_date') && (
<p className="text-xs text-error">{fieldError('end_date')}</p>
)}
</div>
</div>
@ -97,16 +129,22 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
{globalError}
</div>
)}
</form>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
<SubmitButton />
<Button type="submit" form="start-sprint-form" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}