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,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