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 { revalidatePath } from 'next/cache'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import { getIronSession } from 'iron-session'
|
import { getIronSession } from 'iron-session'
|
||||||
import { z } from 'zod'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { getAccessibleProduct } from '@/lib/product-access'
|
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 { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server'
|
||||||
import { pbiStatusFromApi } from '@/lib/task-status'
|
import { pbiStatusFromApi } from '@/lib/task-status'
|
||||||
|
import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
|
type PbiFieldErrors = Record<string, string[]>
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
const parsed = createPbiSchema.safeParse({
|
const parsed = createPbiSchema.safeParse({
|
||||||
productId: formData.get('productId'),
|
productId: formData.get('productId'),
|
||||||
|
|
@ -50,18 +30,34 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
||||||
priority: formData.get('priority'),
|
priority: formData.get('priority'),
|
||||||
status: (formData.get('status') as string) || undefined,
|
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)
|
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)
|
const manualCode = normalizeCode(parsed.data.code)
|
||||||
if (manualCode !== null && !isValidCode(manualCode)) {
|
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) {
|
if (manualCode) {
|
||||||
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: 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({
|
const last = await prisma.pbi.findFirst({
|
||||||
|
|
@ -94,7 +90,11 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
||||||
(code) => insert(code),
|
(code) => insert(code),
|
||||||
)
|
)
|
||||||
} catch {
|
} 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}`)
|
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) {
|
export async function updatePbiAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
const parsed = updatePbiSchema.safeParse({
|
const parsed = updatePbiSchema.safeParse({
|
||||||
id: formData.get('id'),
|
id: formData.get('id'),
|
||||||
|
|
@ -114,25 +114,41 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
|
||||||
priority: formData.get('priority'),
|
priority: formData.get('priority'),
|
||||||
status: (formData.get('status') as string) || undefined,
|
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({
|
const pbi = await prisma.pbi.findFirst({
|
||||||
where: { id: parsed.data.id },
|
where: { id: parsed.data.id },
|
||||||
include: { product: true },
|
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)
|
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)
|
const code = normalizeCode(parsed.data.code)
|
||||||
if (code !== null && !isValidCode(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) {
|
if (code) {
|
||||||
const dup = await prisma.pbi.findFirst({
|
const dup = await prisma.pbi.findFirst({
|
||||||
where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } },
|
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
|
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) {
|
export async function deletePbiAction(id: string) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findFirst({
|
const pbi = await prisma.pbi.findFirst({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { product: true },
|
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)
|
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 } })
|
await prisma.pbi.delete({ where: { id } })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,29 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useActionState } from 'react'
|
import { useActionState } from 'react'
|
||||||
import { useFormStatus } from 'react-dom'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { PrioritySelect } from '@/components/shared/priority-select'
|
import { PrioritySelect } from '@/components/shared/priority-select'
|
||||||
import { PbiStatusSelect } from '@/components/shared/pbi-status-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 { createPbiAction, updatePbiAction } from '@/actions/pbis'
|
||||||
import type { PbiStatusApi } from '@/lib/task-status'
|
import type { PbiStatusApi } from '@/lib/task-status'
|
||||||
|
|
||||||
|
|
@ -36,18 +44,18 @@ export type PbiDialogState = CreateState | EditState
|
||||||
interface PbiDialogProps {
|
interface PbiDialogProps {
|
||||||
state: PbiDialogState | null
|
state: PbiDialogState | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
isDemo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmitButton({ label }: { label: string }) {
|
interface ActionResult {
|
||||||
const { pending } = useFormStatus()
|
success?: boolean
|
||||||
return (
|
error?: string
|
||||||
<Button type="submit" disabled={pending}>
|
code?: number
|
||||||
{pending ? '…' : label}
|
fieldErrors?: Record<string, string[]>
|
||||||
</Button>
|
pbi?: unknown
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) {
|
||||||
const isEdit = state?.mode === 'edit'
|
const isEdit = state?.mode === 'edit'
|
||||||
const pbi = isEdit ? state.pbi : null
|
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 initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready'
|
||||||
const [status, setStatus] = useState<PbiStatusApi>(initialStatus)
|
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(() => {
|
useEffect(() => {
|
||||||
if (state) {
|
if (state) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
|
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
|
||||||
|
|
||||||
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
|
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
|
||||||
|
setDirty(false)
|
||||||
}
|
}
|
||||||
}, [state, isEdit])
|
}, [state, isEdit])
|
||||||
|
|
||||||
const [createState, createAction] = useActionState(
|
const [createState, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev, fd) => {
|
||||||
const result = await createPbiAction(_prev, fd)
|
const result = await createPbiAction(_prev, fd) as ActionResult
|
||||||
if (result?.success) { toast.success('PBI aangemaakt'); onClose() }
|
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
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [updateState, updateAction] = useActionState(
|
const [updateState, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev, fd) => {
|
||||||
const result = await updatePbiAction(_prev, fd)
|
const result = await updatePbiAction(_prev, fd) as ActionResult
|
||||||
if (result?.success) { toast.success('PBI opgeslagen'); onClose() }
|
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
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pending = isEdit ? updatePending : createPending
|
||||||
|
|
||||||
const activeState = isEdit ? updateState : createState
|
const activeState = isEdit ? updateState : createState
|
||||||
const error = typeof activeState?.error === 'string' ? activeState.error : null
|
const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0]
|
||||||
const fieldError = (field: string) => {
|
|
||||||
const err = activeState?.error
|
|
||||||
if (!err || typeof err === 'string') return undefined
|
|
||||||
return (err as Record<string, string[]>)[field]?.[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -102,84 +110,112 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
|
const closeGuard = useDirtyCloseGuard(dirty, onClose)
|
||||||
|
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}>
|
<>
|
||||||
<DialogContent className="sm:max-w-md">
|
<Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
|
||||||
<DialogHeader>
|
<DialogContent
|
||||||
<DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle>
|
showCloseButton={false}
|
||||||
</DialogHeader>
|
onKeyDown={handleKeyDown}
|
||||||
|
className={entityDialogContentClasses}
|
||||||
|
>
|
||||||
|
<div className={entityDialogHeaderClasses}>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4">
|
<form
|
||||||
{isEdit && <input type="hidden" name="id" value={pbi!.id} />}
|
ref={formRef}
|
||||||
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
|
id="pbi-form"
|
||||||
<input type="hidden" name="priority" value={priority} />
|
key={isEdit ? pbi!.id : 'create'}
|
||||||
<input type="hidden" name="status" value={status} />
|
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} />
|
||||||
|
<input type="hidden" name="status" value={status} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[6rem_1fr] gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<label htmlFor="pbi-code" className="text-sm font-medium">Code</label>
|
||||||
|
<Input
|
||||||
|
id="pbi-code"
|
||||||
|
name="code"
|
||||||
|
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 <span className="text-error">*</span></label>
|
||||||
|
<Input
|
||||||
|
id="pbi-title"
|
||||||
|
ref={titleRef}
|
||||||
|
name="title"
|
||||||
|
defaultValue={pbi?.title ?? ''}
|
||||||
|
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>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={(v) => { setPriority(v); setDirty(true) }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<label className="text-sm font-medium">Status</label>
|
||||||
|
<PbiStatusSelect value={status} onChange={(v) => { setStatus(v); setDirty(true) }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[6rem_1fr] gap-3">
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<label htmlFor="pbi-code" className="text-sm font-medium">Code</label>
|
<label htmlFor="pbi-description" className="text-sm font-medium">
|
||||||
<Input
|
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
|
||||||
id="pbi-code"
|
</label>
|
||||||
name="code"
|
<Textarea
|
||||||
defaultValue={pbi?.code ?? ''}
|
id="pbi-description"
|
||||||
placeholder={isEdit ? '' : 'auto'}
|
name="description"
|
||||||
maxLength={30}
|
defaultValue={pbi?.description ?? ''}
|
||||||
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
|
placeholder="Korte omschrijving van het PBI…"
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
disabled={isDemo}
|
||||||
|
className="resize-none"
|
||||||
/>
|
/>
|
||||||
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
</form>
|
||||||
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
|
|
||||||
<Input
|
<div className={entityDialogFooterClasses}>
|
||||||
id="pbi-title"
|
<div className="flex items-center justify-end gap-2">
|
||||||
ref={titleRef}
|
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
||||||
name="title"
|
Annuleren
|
||||||
defaultValue={pbi?.title ?? ''}
|
</Button>
|
||||||
placeholder="PBI-titel…"
|
<DemoTooltip show={isDemo}>
|
||||||
required
|
<Button type="submit" form="pbi-form" disabled={pending || isDemo}>
|
||||||
maxLength={200}
|
{pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
|
||||||
className={fieldError('title') ? 'border-error' : ''}
|
</Button>
|
||||||
/>
|
</DemoTooltip>
|
||||||
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogContent>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</Dialog>
|
||||||
<div className="grid gap-1.5">
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||||
<label className="text-sm font-medium">Prioriteit</label>
|
</>
|
||||||
<PrioritySelect value={priority} onChange={setPriority} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<label className="text-sm font-medium">Status</label>
|
|
||||||
<PbiStatusSelect value={status} onChange={setStatus} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<label htmlFor="pbi-description" className="text-sm font-medium">
|
|
||||||
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
id="pbi-description"
|
|
||||||
name="description"
|
|
||||||
defaultValue={pbi?.description ?? ''}
|
|
||||||
placeholder="Korte omschrijving van het PBI…"
|
|
||||||
rows={3}
|
|
||||||
maxLength={2000}
|
|
||||||
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>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,7 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
<PbiDialog
|
<PbiDialog
|
||||||
state={dialogState}
|
state={dialogState}
|
||||||
onClose={() => setDialogState(null)}
|
onClose={() => setDialogState(null)}
|
||||||
|
isDemo={isDemo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
||||||
|
|
||||||
| Title | Status | Updated |
|
| 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 |
|
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
|
||||||
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 |
|
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 |
|
||||||
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
|
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ title: "PbiDialog Profiel"
|
||||||
status: active
|
status: active
|
||||||
audience: [ai-agent, contributor]
|
audience: [ai-agent, contributor]
|
||||||
language: nl
|
language: nl
|
||||||
last_updated: 2026-05-03
|
last_updated: 2026-05-04
|
||||||
---
|
---
|
||||||
|
|
||||||
# PbiDialog Profiel
|
# PbiDialog Profiel
|
||||||
|
|
@ -74,7 +74,11 @@ Beide acties moeten de drielaagse demo-policy volgen (zie § Bekende gaps).
|
||||||
|
|
||||||
### Form-state via `useActionState`
|
### 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>`
|
### `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 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.
|
||||||
- ❌ **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
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