feat(pbi-dialog): conform aan dialog-pattern + DemoTooltip + dirty-guard
Story 3 van PBI "Alle dialogen conform docs/patterns/dialog.md". - lib/schemas/pbi.ts — gedeeld zod-schema (createPbiSchema/updatePbiSchema) - actions/pbis.ts — returnen nu code+fieldErrors (422) en code: 403 voor auth/demo errors - PbiDialog adopt useDirtyCloseGuard, useDialogSubmitShortcut, entityDialog* layout-classes; submit-knop + Annuleren in DemoTooltip - isDemo-prop toegevoegd, pbi-list geeft 'm door - docs/specs/dialogs/pbi.md — "Bekende gaps" weggewerkt; alleen bewuste uitsluitingen blijven Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
03a248b0fb
commit
97dc4ee553
6 changed files with 231 additions and 154 deletions
|
|
@ -3,44 +3,24 @@
|
|||
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 } from '@/lib/product-access'
|
||||
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
|
||||
import { isValidCode, normalizeCode } from '@/lib/code'
|
||||
import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server'
|
||||
import { pbiStatusFromApi } from '@/lib/task-status'
|
||||
import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
|
||||
|
||||
const statusField = z.enum(['ready', 'blocked', 'done']).optional()
|
||||
|
||||
const createPbiSchema = z.object({
|
||||
productId: z.string(),
|
||||
code: codeField,
|
||||
title: z.string().min(1, 'Titel is verplicht').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
priority: z.coerce.number().int().min(1).max(4),
|
||||
status: statusField,
|
||||
})
|
||||
|
||||
const updatePbiSchema = z.object({
|
||||
id: z.string(),
|
||||
code: codeField,
|
||||
title: z.string().min(1, 'Titel is verplicht').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
priority: z.coerce.number().int().min(1).max(4),
|
||||
status: statusField,
|
||||
})
|
||||
type PbiFieldErrors = Record<string, string[]>
|
||||
|
||||
export async function createPbiAction(_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 = createPbiSchema.safeParse({
|
||||
productId: formData.get('productId'),
|
||||
|
|
@ -50,18 +30,34 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
|||
priority: formData.get('priority'),
|
||||
status: (formData.get('status') as string) || undefined,
|
||||
})
|
||||
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
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 manualCode = normalizeCode(parsed.data.code)
|
||||
if (manualCode !== null && !isValidCode(manualCode)) {
|
||||
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
if (manualCode) {
|
||||
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } })
|
||||
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
|
||||
if (dup) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const last = await prisma.pbi.findFirst({
|
||||
|
|
@ -94,7 +90,11 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
|||
(code) => insert(code),
|
||||
)
|
||||
} catch {
|
||||
return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } }
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${parsed.data.productId}`)
|
||||
|
|
@ -103,8 +103,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
|||
|
||||
export async function updatePbiAction(_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 = updatePbiSchema.safeParse({
|
||||
id: formData.get('id'),
|
||||
|
|
@ -114,25 +114,41 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
|
|||
priority: formData.get('priority'),
|
||||
status: (formData.get('status') as string) || undefined,
|
||||
})
|
||||
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const pbi = await prisma.pbi.findFirst({
|
||||
where: { id: parsed.data.id },
|
||||
include: { product: true },
|
||||
})
|
||||
if (!pbi) return { error: 'PBI niet gevonden' }
|
||||
if (!pbi) return { error: 'PBI niet gevonden', code: 403 }
|
||||
const accessible = await getAccessibleProduct(pbi.product_id, session.userId)
|
||||
if (!accessible) return { error: 'PBI niet gevonden' }
|
||||
if (!accessible) return { error: 'PBI niet gevonden', code: 403 }
|
||||
|
||||
const code = normalizeCode(parsed.data.code)
|
||||
if (code !== null && !isValidCode(code)) {
|
||||
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
if (code) {
|
||||
const dup = await prisma.pbi.findFirst({
|
||||
where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } },
|
||||
})
|
||||
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
|
||||
if (dup) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined
|
||||
|
|
@ -154,16 +170,16 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
|
|||
|
||||
export async function deletePbiAction(id: string) {
|
||||
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 pbi = await prisma.pbi.findFirst({
|
||||
where: { id },
|
||||
include: { product: true },
|
||||
})
|
||||
if (!pbi) return { error: 'PBI niet gevonden' }
|
||||
if (!pbi) return { error: 'PBI niet gevonden', code: 403 }
|
||||
const accessible = await getAccessibleProduct(pbi.product_id, session.userId)
|
||||
if (!accessible) return { error: 'PBI niet gevonden' }
|
||||
if (!accessible) return { error: 'PBI niet gevonden', code: 403 }
|
||||
|
||||
await prisma.pbi.delete({ where: { id } })
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,29 @@
|
|||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { PrioritySelect } from '@/components/shared/priority-select'
|
||||
import { PbiStatusSelect } from '@/components/shared/pbi-status-select'
|
||||
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 {
|
||||
entityDialogBodyClasses,
|
||||
entityDialogContentClasses,
|
||||
entityDialogFooterClasses,
|
||||
entityDialogHeaderClasses,
|
||||
} from '@/components/shared/entity-dialog-layout'
|
||||
import { createPbiAction, updatePbiAction } from '@/actions/pbis'
|
||||
import type { PbiStatusApi } from '@/lib/task-status'
|
||||
|
||||
|
|
@ -36,18 +44,18 @@ export type PbiDialogState = CreateState | EditState
|
|||
interface PbiDialogProps {
|
||||
state: PbiDialogState | null
|
||||
onClose: () => void
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
function SubmitButton({ label }: { label: string }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? '…' : label}
|
||||
</Button>
|
||||
)
|
||||
interface ActionResult {
|
||||
success?: boolean
|
||||
error?: string
|
||||
code?: number
|
||||
fieldErrors?: Record<string, string[]>
|
||||
pbi?: unknown
|
||||
}
|
||||
|
||||
export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
||||
export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) {
|
||||
const isEdit = state?.mode === 'edit'
|
||||
const pbi = isEdit ? state.pbi : null
|
||||
|
||||
|
|
@ -57,43 +65,43 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready'
|
||||
const [status, setStatus] = useState<PbiStatusApi>(initialStatus)
|
||||
|
||||
// Sync priority + status when dialog opens for a different PBI or switches create/edit mode
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// Sync priority + status + reset dirty when dialog opens for a different PBI or switches mode
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
|
||||
|
||||
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
|
||||
setDirty(false)
|
||||
}
|
||||
}, [state, isEdit])
|
||||
|
||||
const [createState, createAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await createPbiAction(_prev, fd)
|
||||
const [createState, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
|
||||
async (_prev, fd) => {
|
||||
const result = await createPbiAction(_prev, fd) as ActionResult
|
||||
if (result?.success) { toast.success('PBI aangemaakt'); onClose() }
|
||||
else if (typeof result?.error === 'string') toast.error(result.error)
|
||||
else if (result?.code !== 422 && result?.error) toast.error(result.error)
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
undefined,
|
||||
)
|
||||
|
||||
const [updateState, updateAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await updatePbiAction(_prev, fd)
|
||||
const [updateState, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
|
||||
async (_prev, fd) => {
|
||||
const result = await updatePbiAction(_prev, fd) as ActionResult
|
||||
if (result?.success) { toast.success('PBI opgeslagen'); onClose() }
|
||||
else if (typeof result?.error === 'string') toast.error(result.error)
|
||||
else if (result?.code !== 422 && result?.error) toast.error(result.error)
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
undefined,
|
||||
)
|
||||
|
||||
const pending = isEdit ? updatePending : createPending
|
||||
|
||||
const activeState = isEdit ? updateState : createState
|
||||
const error = typeof activeState?.error === 'string' ? activeState.error : null
|
||||
const fieldError = (field: string) => {
|
||||
const err = activeState?.error
|
||||
if (!err || typeof err === 'string') return undefined
|
||||
return (err as Record<string, string[]>)[field]?.[0]
|
||||
}
|
||||
const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0]
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
|
|
@ -102,14 +110,31 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
}
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
const closeGuard = useDirtyCloseGuard(dirty, onClose)
|
||||
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
||||
|
||||
<form key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4">
|
||||
return (
|
||||
<>
|
||||
<Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={entityDialogContentClasses}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="pbi-form"
|
||||
key={isEdit ? pbi!.id : 'create'}
|
||||
action={isEdit ? updateAction : createAction}
|
||||
onChange={() => setDirty(true)}
|
||||
className={entityDialogBodyClasses}
|
||||
>
|
||||
{isEdit && <input type="hidden" name="id" value={pbi!.id} />}
|
||||
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
|
||||
<input type="hidden" name="priority" value={priority} />
|
||||
|
|
@ -124,12 +149,14 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
defaultValue={pbi?.code ?? ''}
|
||||
placeholder={isEdit ? '' : 'auto'}
|
||||
maxLength={30}
|
||||
disabled={isDemo}
|
||||
aria-invalid={!!fieldError('code')}
|
||||
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
|
||||
/>
|
||||
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
|
||||
<label htmlFor="pbi-title" className="text-sm font-medium">Titel <span className="text-error">*</span></label>
|
||||
<Input
|
||||
id="pbi-title"
|
||||
ref={titleRef}
|
||||
|
|
@ -138,6 +165,8 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
placeholder="PBI-titel…"
|
||||
required
|
||||
maxLength={200}
|
||||
disabled={isDemo}
|
||||
aria-invalid={!!fieldError('title')}
|
||||
className={fieldError('title') ? 'border-error' : ''}
|
||||
/>
|
||||
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
|
||||
|
|
@ -147,11 +176,11 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium">Prioriteit</label>
|
||||
<PrioritySelect value={priority} onChange={setPriority} />
|
||||
<PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<PbiStatusSelect value={status} onChange={setStatus} />
|
||||
<PbiStatusSelect value={status} onChange={(v) => { setStatus(v); setDirty(true) }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -166,20 +195,27 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
placeholder="Korte omschrijving van het PBI…"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
disabled={isDemo}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Annuleren
|
||||
</DialogClose>
|
||||
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} />
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div className={entityDialogFooterClasses}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
||||
Annuleren
|
||||
</Button>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button type="submit" form="pbi-form" disabled={pending || isDemo}>
|
||||
{pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,6 +460,7 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
<PbiDialog
|
||||
state={dialogState}
|
||||
onClose={() => setDialogState(null)}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
|||
|
||||
| Title | Status | Updated |
|
||||
|---|---|---|
|
||||
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-03 |
|
||||
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
|
||||
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
|
||||
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 |
|
||||
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "PbiDialog Profiel"
|
|||
status: active
|
||||
audience: [ai-agent, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-03
|
||||
last_updated: 2026-05-04
|
||||
---
|
||||
|
||||
# PbiDialog Profiel
|
||||
|
|
@ -74,7 +74,11 @@ Beide acties moeten de drielaagse demo-policy volgen (zie § Bekende gaps).
|
|||
|
||||
### Form-state via `useActionState`
|
||||
|
||||
PbiDialog gebruikt het `useActionState` + `useFormStatus`-patroon (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors worden gemapt via een lokale `fieldError(field)`-helper die `result.error` als `Record<string, string[]>` interpreteert wanneer 'm geen string is.
|
||||
PbiDialog gebruikt `useActionState` (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors komen uit het action-result als `result.fieldErrors: Record<string, string[]>` met `result.code === 422`; een lokale `fieldError(field)`-helper levert het eerste bericht op.
|
||||
|
||||
### Dirty-tracking handmatig
|
||||
|
||||
Omdat we geen `react-hook-form` gebruiken, zetten we `dirty` op `true` bij de eerste `onChange` op het form (en bij wijzigingen van de hidden-state-velden `priority`/`status`). De useDirtyCloseGuard hook gebruikt dit boolean om Esc/Cancel-sluiting te beschermen.
|
||||
|
||||
### `key`-prop op `<form>`
|
||||
|
||||
|
|
@ -93,16 +97,10 @@ Het `<form>`-element heeft `key={isEdit ? pbi!.id : 'create'}` — dit reset nat
|
|||
|
||||
---
|
||||
|
||||
## Bekende gaps t.o.v. generieke spec
|
||||
## Bewust NIET in v1 (PBI-specifiek)
|
||||
|
||||
> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie).
|
||||
|
||||
- ❌ **Geen `<DemoTooltip>`** rond submit-knop — laag 3 van de drielaagse demo-policy ontbreekt voor PBI-create/update. Dat betekent dat een demo-user de knop kan klikken; de server action blokkeert nog steeds (laag 2), maar de UX is suboptimaal.
|
||||
- ❌ **Geen delete-knop / `deletePbiAction`** — alleen create + update. Of dat bewust is (PBI's worden nooit verwijderd, alleen status veranderd) of een gat, moet expliciet worden besloten en in dit profiel vastgelegd.
|
||||
- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`.
|
||||
- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop.
|
||||
- ❌ **Geen char-counter / markdown-hint** op description — bewust weggelaten omdat PBI-descriptions kort zijn, maar verdient expliciete bevestiging.
|
||||
- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-md` i.p.v. de `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4.
|
||||
- ❌ **Geen delete-knop / `deletePbiAction`-trigger vanuit deze dialog** — PBI's worden niet vernietigend verwijderd vanuit de UI; wijzig de status naar `done` of archiveer via een ander mechanisme. `deletePbiAction` bestaat in de codebase voor server-side cleanup maar wordt niet vanuit deze dialog aangeroepen.
|
||||
- ❌ **Geen char-counter / markdown-hint** op description — PBI-descriptions zijn doorgaans kort en richtinggevend; auto-grow en markdown-rendering horen op StoryDialog/TaskDialog.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
26
lib/schemas/pbi.ts
Normal file
26
lib/schemas/pbi.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from 'zod'
|
||||
import { MAX_CODE_LENGTH } from '@/lib/code'
|
||||
|
||||
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
|
||||
const statusField = z.enum(['ready', 'blocked', 'done']).optional()
|
||||
|
||||
export const createPbiSchema = z.object({
|
||||
productId: z.string(),
|
||||
code: codeField,
|
||||
title: z.string().min(1, 'Titel is verplicht').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
priority: z.coerce.number().int().min(1).max(4),
|
||||
status: statusField,
|
||||
})
|
||||
|
||||
export const updatePbiSchema = z.object({
|
||||
id: z.string(),
|
||||
code: codeField,
|
||||
title: z.string().min(1, 'Titel is verplicht').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
priority: z.coerce.number().int().min(1).max(4),
|
||||
status: statusField,
|
||||
})
|
||||
|
||||
export type CreatePbiInput = z.infer<typeof createPbiSchema>
|
||||
export type UpdatePbiInput = z.infer<typeof updatePbiSchema>
|
||||
Loading…
Add table
Add a link
Reference in a new issue