From 4103e36900273f4825fd9ebb9c921c6c8e0b1aef Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 17:35:18 +0200 Subject: [PATCH] feat(ST-?): ProductDialog component (create + edit modes) Voegt components/dialogs/product-dialog.tsx toe op basis van het entity-dialog-patroon. Gebruikt react-hook-form + zodResolver voor client-side validatie. Roept createProductAction/updateProductAction aan en werkt stores/products-store.ts optimistisch bij. Demo-modus disabled alle velden + submit-knop via DemoTooltip. 7 tests groen. Co-Authored-By: Claude Sonnet 4.6 --- .../dialogs/product-dialog.test.tsx | 131 +++++++++ components/dialogs/product-dialog.tsx | 270 ++++++++++++++++++ stores/products-store.ts | 29 ++ 3 files changed, 430 insertions(+) create mode 100644 __tests__/components/dialogs/product-dialog.test.tsx create mode 100644 components/dialogs/product-dialog.tsx create mode 100644 stores/products-store.ts diff --git a/__tests__/components/dialogs/product-dialog.test.tsx b/__tests__/components/dialogs/product-dialog.test.tsx new file mode 100644 index 0000000..0fc6f79 --- /dev/null +++ b/__tests__/components/dialogs/product-dialog.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +vi.mock('@/actions/products', () => ({ + createProductAction: vi.fn(), + updateProductAction: vi.fn(), +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/stores/products-store', () => ({ + useProductsStore: vi.fn((selector: (s: { addProduct: () => void; updateProduct: () => void }) => unknown) => + selector({ addProduct: vi.fn(), updateProduct: vi.fn() }) + ), +})) + +import { ProductDialog } from '@/components/dialogs/product-dialog' +import { createProductAction, updateProductAction } from '@/actions/products' +import { toast } from 'sonner' + +const mockCreate = createProductAction as ReturnType +const mockUpdate = updateProductAction as ReturnType +const mockToast = toast as { success: ReturnType; error: ReturnType } + +const PRODUCT = { + id: 'prod-1', + name: 'Mijn Product', + code: 'MP', + description: 'Een product', + repo_url: 'https://github.com/org/repo', + definition_of_done: 'Alles groen', + auto_pr: false, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('ProductDialog — create mode', () => { + it('rendert met lege velden en "Nieuw product" titel', () => { + render( + + ) + expect(screen.getByText('Nieuw product')).toBeTruthy() + expect(screen.getByLabelText(/Naam/)).toBeTruthy() + expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('') + }) + + it('toont validatiefout als naam leeg is bij submit', async () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Aanmaken' })) + + await waitFor(() => { + expect(screen.getByText('Naam is verplicht')).toBeTruthy() + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('roept createProductAction aan bij geldig formulier', async () => { + mockCreate.mockResolvedValue({ success: true, productId: 'new-prod' }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Nieuw Product' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Nieuw Product' }) + ) + }) + expect(mockToast.success).toHaveBeenCalledWith('Product aangemaakt') + }) + + it('toont error-toast als createProductAction een error retourneert', async () => { + mockCreate.mockResolvedValue({ error: 'Code is al in gebruik' }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Test' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith('Code is al in gebruik') + }) + }) +}) + +describe('ProductDialog — edit mode', () => { + it('rendert met bestaande waarden vooringevuld', () => { + render( + + ) + expect(screen.getByText('Product bewerken')).toBeTruthy() + expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('Mijn Product') + }) + + it('roept updateProductAction aan bij opslaan', async () => { + mockUpdate.mockResolvedValue({ success: true }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Gewijzigd Product' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + PRODUCT.id, + expect.objectContaining({ name: 'Gewijzigd Product' }) + ) + }) + expect(mockToast.success).toHaveBeenCalledWith('Product opgeslagen') + }) +}) + +describe('ProductDialog — demo mode', () => { + it('submit-knop is disabled in demo-modus', () => { + render( + + ) + const submitBtn = screen.getByRole('button', { name: 'Aanmaken' }) + expect(submitBtn).toHaveProperty('disabled', true) + }) +}) diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx new file mode 100644 index 0000000..920b5b7 --- /dev/null +++ b/components/dialogs/product-dialog.tsx @@ -0,0 +1,270 @@ +'use client' + +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 { 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 + code?: string | null + description?: string | null + repo_url?: string | null + definition_of_done?: string | null + auto_pr?: boolean +} + +type Props = + | { mode: 'create'; open: boolean; onOpenChange: (v: boolean) => void; onSaved?: (id: string) => void; isDemo?: boolean } + | { mode: 'edit'; open: boolean; onOpenChange: (v: boolean) => void; product: ProductDialogProduct; onSaved?: (id: string) => void; isDemo?: boolean } + +export function ProductDialog(props: Props) { + const { mode, open, onOpenChange, isDemo = false } = props + const product = mode === 'edit' ? props.product : null + const addProduct = useProductsStore((s) => s.addProduct) + const updateProduct = useProductsStore((s) => s.updateProduct) + + const [isPending, setIsPending] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: product?.name ?? '', + code: product?.code ?? '', + description: product?.description ?? '', + repo_url: product?.repo_url ?? '', + definition_of_done: product?.definition_of_done ?? '', + auto_pr: product?.auto_pr ?? false, + }, + }) + + // Reset form when dialog opens or switches product + useEffect(() => { + if (open) { + form.reset({ + name: product?.name ?? '', + code: product?.code ?? '', + description: product?.description ?? '', + repo_url: product?.repo_url ?? '', + definition_of_done: product?.definition_of_done ?? '', + auto_pr: product?.auto_pr ?? false, + }) + } + // 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 + } + setIsPending(true) + try { + const payload = { + name: values.name, + code: values.code || undefined, + description: values.description || undefined, + repo_url: values.repo_url || null, + definition_of_done: values.definition_of_done || undefined, + auto_pr: values.auto_pr, + } + + if (mode === 'create') { + const result = await createProductAction(payload) + if ('error' in result) { + toast.error(result.error) + return + } + addProduct({ + id: result.productId, + name: values.name, + code: values.code ?? null, + description: values.description ?? null, + repo_url: values.repo_url ?? null, + definition_of_done: values.definition_of_done ?? '', + auto_pr: values.auto_pr, + }) + toast.success('Product aangemaakt') + onOpenChange(false) + props.onSaved?.(result.productId) + } else { + const result = await updateProductAction(product!.id, payload) + if ('error' in result) { + toast.error(result.error) + return + } + updateProduct(product!.id, { + name: values.name, + code: values.code ?? null, + description: values.description ?? null, + repo_url: values.repo_url ?? null, + definition_of_done: values.definition_of_done ?? '', + auto_pr: values.auto_pr, + }) + toast.success('Product opgeslagen') + onOpenChange(false) + props.onSaved?.(product!.id) + } + } finally { + setIsPending(false) + } + } + + const autoPr = form.watch('auto_pr') + + return ( + + + + {mode === 'edit' ? 'Product bewerken' : 'Nieuw product'} + + +
+
+ + + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ +
+ + +
+ +
+ +