From b05c4d241bf61c738fcb62e9d2f1bc3719e86a6d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 07:14:07 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(dialogs):=20gedeelde=20primitives=20?= =?UTF-8?q?=E2=80=94=20useDirtyCloseGuard,=20useDialogSubmitShortcut,=20la?= =?UTF-8?q?yout-classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 1 van PBI "Alle dialogen conform docs/patterns/dialog.md". - components/shared/use-dirty-close-guard.tsx — hook + paired AlertDialog - components/shared/use-dialog-submit-shortcut.ts — Cmd/Ctrl+Enter handler - components/shared/entity-dialog-layout.ts — MD3-conforme classes voor §4 - TaskDialog refactored om beide hooks + classes te gebruiken (geen gedragsverandering) - 8 nieuwe unit-tests Co-Authored-By: Claude Opus 4.7 (1M context) --- .../use-dialog-submit-shortcut.test.ts | 57 ++++++++++++++++ .../components/use-dirty-close-guard.test.tsx | 50 ++++++++++++++ app/_components/tasks/task-dialog.tsx | 67 ++++++------------- components/shared/entity-dialog-layout.ts | 16 +++++ .../shared/use-dialog-submit-shortcut.ts | 10 +++ components/shared/use-dirty-close-guard.tsx | 66 ++++++++++++++++++ 6 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 __tests__/components/use-dialog-submit-shortcut.test.ts create mode 100644 __tests__/components/use-dirty-close-guard.test.tsx create mode 100644 components/shared/entity-dialog-layout.ts create mode 100644 components/shared/use-dialog-submit-shortcut.ts create mode 100644 components/shared/use-dirty-close-guard.tsx diff --git a/__tests__/components/use-dialog-submit-shortcut.test.ts b/__tests__/components/use-dialog-submit-shortcut.test.ts new file mode 100644 index 0000000..a53e041 --- /dev/null +++ b/__tests__/components/use-dialog-submit-shortcut.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' + +function makeEvent(opts: Partial) { + return { + metaKey: false, + ctrlKey: false, + key: '', + preventDefault: vi.fn(), + ...opts, + } as unknown as React.KeyboardEvent +} + +describe('useDialogSubmitShortcut', () => { + it('triggert submit op Cmd+Enter', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ metaKey: true, key: 'Enter' }) + + handler(e) + + expect(submit).toHaveBeenCalledTimes(1) + expect(e.preventDefault).toHaveBeenCalled() + }) + + it('triggert submit op Ctrl+Enter', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ ctrlKey: true, key: 'Enter' }) + + handler(e) + + expect(submit).toHaveBeenCalledTimes(1) + }) + + it('triggert NIET op Enter zonder modifier', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ key: 'Enter' }) + + handler(e) + + expect(submit).not.toHaveBeenCalled() + expect(e.preventDefault).not.toHaveBeenCalled() + }) + + it('triggert NIET op Cmd+andere toets', () => { + const submit = vi.fn() + const handler = useDialogSubmitShortcut(submit) + const e = makeEvent({ metaKey: true, key: 'a' }) + + handler(e) + + expect(submit).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/components/use-dirty-close-guard.test.tsx b/__tests__/components/use-dirty-close-guard.test.tsx new file mode 100644 index 0000000..1220817 --- /dev/null +++ b/__tests__/components/use-dirty-close-guard.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard' + +describe('useDirtyCloseGuard', () => { + it('sluit direct als form niet dirty is', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(false, onClose)) + + act(() => result.current.attemptClose()) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(result.current.confirmOpen).toBe(false) + }) + + it('opent confirm als form dirty is', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.confirmOpen).toBe(true) + }) + + it('confirmDiscard sluit confirm en roept onClose', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + expect(result.current.confirmOpen).toBe(true) + + act(() => result.current.confirmDiscard()) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(result.current.confirmOpen).toBe(false) + }) + + it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) + + act(() => result.current.attemptClose()) + act(() => result.current.setConfirmOpen(false)) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.confirmOpen).toBe(false) + }) +}) diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 2426dc1..abb9ce3 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -28,6 +28,17 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' 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 { PrioritySegmented } from './priority-segmented' import { StatusSelect } from './status-select' import { cn } from '@/lib/utils' @@ -70,7 +81,6 @@ const textareaClass = cn( export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() - const [confirmClose, setConfirmClose] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const isEdit = !!task @@ -90,20 +100,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false router.push(closePath) } - function handleAttemptClose() { - if (form.formState.isDirty) { - setConfirmClose(true) - } else { - handleClose() - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault() - form.handleSubmit(onSubmit)() - } - } + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) + const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { startTransition(async () => { @@ -167,19 +165,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false return ( <> - { if (!open) handleAttemptClose() }}> + { if (!open) closeGuard.attemptClose() }}> {/* Sticky header */} -
+
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'} @@ -196,7 +189,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Scrollable form body */} -
+
{/* Title */}
{/* Sticky footer */} -
+
{isEdit ? ( @@ -344,7 +337,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Dirty-check confirm */} - - - - Wijzigingen niet opgeslagen - - Wil je de wijzigingen weggooien? - - - - setConfirmClose(false)}> - Terug - - { setConfirmClose(false); handleClose() }} - > - Weggooien - - - - + {/* Delete confirm */} diff --git a/components/shared/entity-dialog-layout.ts b/components/shared/entity-dialog-layout.ts new file mode 100644 index 0000000..c97ddfb --- /dev/null +++ b/components/shared/entity-dialog-layout.ts @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils' + +export const entityDialogContentClasses = cn( + 'flex flex-col p-0 gap-0', + 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', + 'sm:max-w-[90vw] sm:max-h-[85vh]', + 'lg:max-w-[50vw] lg:min-w-[480px]', +) + +export const entityDialogHeaderClasses = + 'flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0' + +export const entityDialogBodyClasses = 'flex-1 overflow-y-auto px-6 py-6 space-y-6' + +export const entityDialogFooterClasses = + 'border-t border-outline-variant px-6 py-4 shrink-0' diff --git a/components/shared/use-dialog-submit-shortcut.ts b/components/shared/use-dialog-submit-shortcut.ts new file mode 100644 index 0000000..669eea6 --- /dev/null +++ b/components/shared/use-dialog-submit-shortcut.ts @@ -0,0 +1,10 @@ +import type { KeyboardEvent } from 'react' + +export function useDialogSubmitShortcut(submit: () => void) { + return (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + submit() + } + } +} diff --git a/components/shared/use-dirty-close-guard.tsx b/components/shared/use-dirty-close-guard.tsx new file mode 100644 index 0000000..2d8005a --- /dev/null +++ b/components/shared/use-dirty-close-guard.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +export interface DirtyCloseGuard { + confirmOpen: boolean + setConfirmOpen: (v: boolean) => void + attemptClose: () => void + confirmDiscard: () => void +} + +export function useDirtyCloseGuard( + isDirty: boolean, + onClose: () => void, +): DirtyCloseGuard { + const [confirmOpen, setConfirmOpen] = useState(false) + + function attemptClose() { + if (isDirty) setConfirmOpen(true) + else onClose() + } + + function confirmDiscard() { + setConfirmOpen(false) + onClose() + } + + return { confirmOpen, setConfirmOpen, attemptClose, confirmDiscard } +} + +export function DirtyCloseGuardDialog({ + guard, +}: { + guard: DirtyCloseGuard +}) { + return ( + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + guard.setConfirmOpen(false)}> + Terug + + + Weggooien + + + + + ) +} From 03a248b0fb2864ab7f1082fc85149cf5d8a5ab6e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 07:18:39 +0200 Subject: [PATCH 2/7] 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'} +
-
- - -
- -
- -