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:
parent
01e77fc560
commit
784791d8f9
7 changed files with 320 additions and 125 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue