diff --git a/actions/pbis.ts b/actions/pbis.ts index f2221e8..2ebf7f9 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -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(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 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 } }) diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 6af393a..0efbba3 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -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 ( - - ) +interface ActionResult { + success?: boolean + error?: string + code?: number + fieldErrors?: Record + 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(initialStatus) - // Sync priority + status when dialog opens for a different PBI or switches create/edit mode + const [dirty, setDirty] = useState(false) + const formRef = useRef(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( + 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( + 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)[field]?.[0] - } + const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0] const titleRef = useRef(null) useEffect(() => { @@ -102,84 +110,112 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { } }, [state]) + const closeGuard = useDirtyCloseGuard(dirty, onClose) + const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) + return ( - { if (!open) onClose() }}> - - - {isEdit ? 'PBI bewerken' : 'Nieuw PBI'} - + <> + { if (!open) closeGuard.attemptClose() }}> + +
+ + {isEdit ? 'PBI bewerken' : 'Nieuw PBI'} + +
-
- {isEdit && } - {!isEdit && } - - + setDirty(true)} + className={entityDialogBodyClasses} + > + {isEdit && } + {!isEdit && } + + + +
+
+ + + {fieldError('code') &&

{fieldError('code')}

} +
+
+ + + {fieldError('title') &&

{fieldError('title')}

} +
+
+ +
+
+ + { setPriority(v); setDirty(true) }} /> +
+
+ + { setStatus(v); setDirty(true) }} /> +
+
-
- - + Beschrijving (optioneel) + +