From 03a248b0fb2864ab7f1082fc85149cf5d8a5ab6e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 07:18:39 +0200 Subject: [PATCH] feat(product-dialog): conform aan dialog-pattern + entity-profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 2 van PBI "Alle dialogen conform docs/patterns/dialog.md". - lib/schemas/product.ts — gedeeld zod-schema (Dialog API) - actions/products.ts — createProductAction/updateProductAction returnen nu code+fieldErrors voor 422-validatie en code: 403 voor demo/auth - ProductDialog adopt useDirtyCloseGuard, useDialogSubmitShortcut, entityDialog* layout-classes; 422-fieldErrors mappen naar form.setError - docs/specs/dialogs/product.md — entity-profile Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/products.test.ts | 5 +- actions/products.ts | 74 +++--- components/dialogs/product-dialog.tsx | 332 ++++++++++++++------------ docs/INDEX.md | 3 +- docs/specs/dialogs/product.md | 59 +++++ lib/schemas/product.ts | 18 ++ 6 files changed, 310 insertions(+), 181 deletions(-) create mode 100644 docs/specs/dialogs/product.md create mode 100644 lib/schemas/product.ts diff --git a/__tests__/actions/products.test.ts b/__tests__/actions/products.test.ts index b2cc766..ed538e0 100644 --- a/__tests__/actions/products.test.ts +++ b/__tests__/actions/products.test.ts @@ -107,7 +107,10 @@ describe('createProductAction', () => { const result = await createProductAction(VALID_DATA) - expect(result).toMatchObject({ error: expect.stringContaining('gebruik') }) + expect(result).toMatchObject({ + code: 422, + fieldErrors: { code: expect.arrayContaining([expect.stringContaining('gebruik')]) }, + }) expect(mockTransaction).not.toHaveBeenCalled() }) diff --git a/actions/products.ts b/actions/products.ts index a43f492..7024c3b 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -10,8 +10,10 @@ import { SessionData, sessionOptions } from '@/lib/session' import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { productAccessFilter } from '@/lib/product-access' +import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product' -const productSchema = z.object({ +// Legacy FormData schema for ProductForm components (other constraints than dialog) +const productFormDataSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), code: z .string() @@ -29,25 +31,13 @@ const productSchema = z.object({ .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), }) -// Dialog-based schema (data-object API) -const productInput = z.object({ - name: z.string().min(1).max(200), - code: z.string().max(20).optional(), - description: z.string().max(4000).optional(), - repo_url: z - .string() - .url() - .regex(/^https:\/\/github\.com\//) - .optional() - .nullable(), - definition_of_done: z.string().max(4000).optional(), - auto_pr: z.boolean().default(false), -}) - -export type ProductInput = z.infer - -type ProductActionResult = { success: true; productId: string } | { error: string } -type UpdateProductResult = { success: true } | { error: string } +type ProductFieldErrors = Partial> +type ProductActionResult = + | { success: true; productId: string } + | { error: string; code?: number; fieldErrors?: ProductFieldErrors } +type UpdateProductResult = + | { success: true } + | { error: string; code?: number; fieldErrors?: ProductFieldErrors } async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -56,20 +46,30 @@ async function getSession() { // Data-object API used by ProductDialog export async function createProductAction(data: ProductInput): Promise { 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 = productInput.safeParse(data) - if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, + } + } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: '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'] }, + } } if (code) { const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) - if (dup) return { error: 'Code is al in gebruik' } + if (dup) return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Code is al in gebruik'] } } } const userId = session.userId @@ -97,21 +97,31 @@ export async function createProductAction(data: ProductInput): Promise { 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 = productInput.safeParse(data) - if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, + } + } const product = await prisma.product.findFirst({ where: { id, ...productAccessFilter(session.userId) }, select: { id: true }, }) - if (!product) return { error: 'Product niet gevonden of geen toegang' } + if (!product) return { error: 'Product niet gevonden of geen toegang', code: 403 } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { error: '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'] }, + } } const userId = session.userId @@ -149,7 +159,7 @@ export async function createProductFormAction(_prevState: unknown, formData: For if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const parsed = productSchema.safeParse({ + const parsed = productFormDataSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, @@ -198,7 +208,7 @@ export async function updateProductFormAction(_prevState: unknown, formData: For const id = formData.get('id') as string if (!id) return { error: 'Product niet gevonden' } - const parsed = productSchema.safeParse({ + const parsed = productFormDataSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx index 920b5b7..a0a9797 100644 --- a/components/dialogs/product-dialog.tsx +++ b/components/dialogs/product-dialog.tsx @@ -3,35 +3,32 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' import { toast } from 'sonner' import { cn } from '@/lib/utils' 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 { 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 { productSchema, type ProductInput } from '@/lib/schemas/product' import { createProductAction, updateProductAction } from '@/actions/products' import { useProductsStore } from '@/stores/products-store' -const formSchema = z.object({ - name: z.string().min(1, 'Naam is verplicht').max(200), - code: z.string().max(20).optional(), - description: z.string().max(4000).optional(), - repo_url: z.string().max(200).optional(), - definition_of_done: z.string().max(4000).optional(), - auto_pr: z.boolean(), -}) - -type FormValues = z.infer - export interface ProductDialogProduct { id: string name: string @@ -54,8 +51,9 @@ export function ProductDialog(props: Props) { const [isPending, setIsPending] = useState(false) - const form = useForm({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: zodResolver(productSchema), + mode: 'onTouched', defaultValues: { name: product?.name ?? '', code: product?.code ?? '', @@ -66,7 +64,7 @@ export function ProductDialog(props: Props) { }, }) - // Reset form when dialog opens or switches product + // Reset when opening or switching product useEffect(() => { if (open) { form.reset({ @@ -81,14 +79,13 @@ export function ProductDialog(props: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, product?.id]) - async function onSubmit(values: FormValues) { - if (isDemo) { - toast.error('Niet beschikbaar in demo-modus') - return - } + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, () => onOpenChange(false)) + const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) + + async function onSubmit(values: ProductInput) { setIsPending(true) try { - const payload = { + const payload: ProductInput = { name: values.name, code: values.code || undefined, description: values.description || undefined, @@ -97,14 +94,29 @@ export function ProductDialog(props: Props) { auto_pr: values.auto_pr, } + function applyError(result: { error: string; code?: number; fieldErrors?: Partial> }) { + if (result.code === 422 && result.fieldErrors) { + for (const [field, errors] of Object.entries(result.fieldErrors)) { + if (errors && errors.length > 0) { + form.setError(field as keyof ProductInput, { message: errors[0] }) + } + } + const firstError = Object.keys(result.fieldErrors)[0] as keyof ProductInput | undefined + if (firstError) form.setFocus(firstError) + return + } + toast.error(result.error) + } + if (mode === 'create') { const result = await createProductAction(payload) if ('error' in result) { - toast.error(result.error) + applyError(result) return } + const productId = result.productId addProduct({ - id: result.productId, + id: productId, name: values.name, code: values.code ?? null, description: values.description ?? null, @@ -114,11 +126,11 @@ export function ProductDialog(props: Props) { }) toast.success('Product aangemaakt') onOpenChange(false) - props.onSaved?.(result.productId) + props.onSaved?.(productId) } else { const result = await updateProductAction(product!.id, payload) if ('error' in result) { - toast.error(result.error) + applyError(result) return } updateProduct(product!.id, { @@ -141,130 +153,156 @@ export function ProductDialog(props: Props) { const autoPr = form.watch('auto_pr') return ( - - - - {mode === 'edit' ? 'Product bewerken' : 'Nieuw product'} - - -
+ { if (!v) closeGuard.attemptClose(); else onOpenChange(v) }}> + -
- - - {form.formState.errors.name && ( -

{form.formState.errors.name.message}

- )} +
+ + {mode === 'edit' ? 'Product bewerken' : 'Nieuw product'} +
-
- - -
- -
- -