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
|
|
@ -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<string, string[]>
|
||||
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||
const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
|
||||
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<string, string[]>
|
||||
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||
const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
|
||||
expect(result.code).toBe(422)
|
||||
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
|
||||
})
|
||||
|
||||
it('blocks demo users', async () => {
|
||||
|
|
|
|||
|
|
@ -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<SessionData>(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<string, string[]>
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
|||
|---|---|---|
|
||||
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
|
||||
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
|
||||
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
|
||||
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-04 |
|
||||
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
|
||||
| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 |
|
||||
|
|
|
|||
72
docs/specs/dialogs/sprint.md
Normal file
72
docs/specs/dialogs/sprint.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: "Sprint Dialogs Profiel"
|
||||
status: active
|
||||
audience: [ai-agent, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-04
|
||||
---
|
||||
|
||||
# Sprint Dialogs Profiel
|
||||
|
||||
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de Sprint-specifieke afwijkingen en keuzes.
|
||||
|
||||
Sprint heeft drie dialog-flows verspreid over twee componenten:
|
||||
|
||||
| Flow | Locatie | Mode |
|
||||
|---|---|---|
|
||||
| Sprint starten | `components/sprint/start-sprint-button.tsx` | create |
|
||||
| Datums bewerken | `components/sprint/sprint-header.tsx` (inline) | edit |
|
||||
| Sprint afronden | `components/sprint/sprint-header.tsx` (inline) | actie-bevestiging |
|
||||
|
||||
Daarnaast bestaat een **inline edit-form** voor de Sprint Goal in `sprint-header.tsx` — dat is geen dialog (geen modale overlay) maar een toggleable form-row.
|
||||
|
||||
## Velden
|
||||
|
||||
### Create / Edit dates
|
||||
|
||||
| Veld | Type | Validatie |
|
||||
|---|---|---|
|
||||
| `sprint_goal` | string (alleen create) | min 1, max 500 |
|
||||
| `start_date` | date \| null | optioneel; `end_date >= start_date` |
|
||||
| `end_date` | date \| null | optioneel; `end_date >= start_date` |
|
||||
|
||||
### Complete sprint
|
||||
|
||||
Geen form-velden. Per story-rij in de sprint kiest de gebruiker `'DONE'` of `'OPEN'` (default `'OPEN'`). De decisions-map wordt direct aan `completeSprintAction` doorgegeven.
|
||||
|
||||
## URL- of state-pattern
|
||||
|
||||
- Gekozen: **state-based** (§11.2)
|
||||
- Reden: alle dialogen zijn local-state in hun parent-component (button of header). Sprint heeft geen deep-link-bare detail-pagina voor zijn dialogen.
|
||||
|
||||
## Server actions
|
||||
|
||||
- `createSprintAction(_prev, fd)` — `actions/sprints.ts` — revalidate `/products/${productId}`
|
||||
- `updateSprintDatesAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
|
||||
- `updateSprintGoalAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
|
||||
- `completeSprintAction(sprintId, decisions)` — niet form-based; directe argumenten
|
||||
- Alle hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter`/`getAccessibleProduct` voor scope
|
||||
- Resultaat-shape: `{ success: true, ... }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }`
|
||||
|
||||
## Foutcodes
|
||||
|
||||
| Code | Wanneer | UI |
|
||||
|---|---|---|
|
||||
| 422 | zod-validatie of date-order constraint | `fieldErrors` onder de velden, geen toast |
|
||||
| 403 | niet ingelogd, demo-modus, of geen toegang | toast met message |
|
||||
|
||||
## Schema
|
||||
|
||||
`lib/schemas/sprint.ts` exporteert:
|
||||
- `createSprintSchema` — productId, sprint_goal, start_date, end_date
|
||||
- `updateSprintDatesSchema` — id, start_date, end_date
|
||||
- `updateSprintGoalSchema` — id, sprint_goal
|
||||
- `validateDateOrder` — refinement gebruikt door beide date-schemas
|
||||
|
||||
Alle drie de actions importeren hier; geen inline schemas meer.
|
||||
|
||||
## Bewust NIET in v1
|
||||
|
||||
- ❌ **Eén consolideerde `SprintDialog`-component** met `mode: 'create' | 'edit-dates' | 'complete'` — overwogen tijdens story 5 maar niet uitgevoerd; de dialogen leven natuurlijker in hun parent-component (button / header) en worden niet hergebruikt elders. Indien een vierde sprint-dialog ontstaat, hernieuw deze afweging.
|
||||
- ❌ Bewerken van de Sprint Goal vanuit deze dialogen — gebeurt via een inline-form in `sprint-header.tsx` (toggleable, geen modal)
|
||||
- ❌ Sprint-templates / kopiëren van vorige sprint
|
||||
38
lib/schemas/sprint.ts
Normal file
38
lib/schemas/sprint.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
|
||||
|
||||
export 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' })
|
||||
}
|
||||
}
|
||||
|
||||
export const createSprintSchema = 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)
|
||||
|
||||
export const updateSprintDatesSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
start_date: dateField,
|
||||
end_date: dateField,
|
||||
})
|
||||
.superRefine(validateDateOrder)
|
||||
|
||||
export const updateSprintGoalSchema = z.object({
|
||||
id: z.string(),
|
||||
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
|
||||
})
|
||||
|
||||
export type CreateSprintInput = z.infer<typeof createSprintSchema>
|
||||
export type UpdateSprintDatesInput = z.infer<typeof updateSprintDatesSchema>
|
||||
export type UpdateSprintGoalInput = z.infer<typeof updateSprintGoalSchema>
|
||||
Loading…
Add table
Add a link
Reference in a new issue