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 <noreply@anthropic.com>
This commit is contained in:
parent
ae66c21109
commit
4103e36900
3 changed files with 430 additions and 0 deletions
131
__tests__/components/dialogs/product-dialog.test.tsx
Normal file
131
__tests__/components/dialogs/product-dialog.test.tsx
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
const mockUpdate = updateProductAction as ReturnType<typeof vi.fn>
|
||||
const mockToast = toast as { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> }
|
||||
|
||||
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(
|
||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
|
||||
)
|
||||
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(
|
||||
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} isDemo={true} />
|
||||
)
|
||||
const submitBtn = screen.getByRole('button', { name: 'Aanmaken' })
|
||||
expect(submitBtn).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
270
components/dialogs/product-dialog.tsx
Normal file
270
components/dialogs/product-dialog.tsx
Normal file
|
|
@ -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<typeof formSchema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="product-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="product-name" className="text-sm font-medium">
|
||||
Naam <span className="text-error">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="product-name"
|
||||
autoFocus={mode === 'create'}
|
||||
disabled={isDemo}
|
||||
maxLength={200}
|
||||
{...form.register('name')}
|
||||
className={form.formState.errors.name ? 'border-error' : ''}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-xs text-error">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="product-code" className="text-sm font-medium">
|
||||
Code <span className="text-muted-foreground font-normal">(optioneel)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="product-code"
|
||||
disabled={isDemo}
|
||||
maxLength={20}
|
||||
placeholder="korte slug, bv. SCRUM4ME"
|
||||
className="font-mono text-sm"
|
||||
{...form.register('code')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="product-description" className="text-sm font-medium">
|
||||
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="product-description"
|
||||
disabled={isDemo}
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
className="resize-none"
|
||||
{...form.register('description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="product-repo-url" className="text-sm font-medium">
|
||||
Repository URL <span className="text-muted-foreground font-normal">(optioneel)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="product-repo-url"
|
||||
disabled={isDemo}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
{...form.register('repo_url')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor="product-dod" className="text-sm font-medium">
|
||||
Definition of Done <span className="text-muted-foreground font-normal">(optioneel)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="product-dod"
|
||||
disabled={isDemo}
|
||||
rows={4}
|
||||
maxLength={4000}
|
||||
className="resize-none"
|
||||
{...form.register('definition_of_done')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoPr}
|
||||
disabled={isDemo}
|
||||
onClick={() => form.setValue('auto_pr', !autoPr)}
|
||||
className={cn(
|
||||
'relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
autoPr ? 'bg-primary' : 'bg-input',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm',
|
||||
'transition-transform duration-200',
|
||||
autoPr ? 'translate-x-4' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">Automatisch PR aanmaken na voltooide story</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Bij elke voltooide story automatisch een PR aanmaken in <code>repo_url</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Annuleren
|
||||
</DialogClose>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button type="submit" form="product-form" disabled={isPending || isDemo}>
|
||||
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
29
stores/products-store.ts
Normal file
29
stores/products-store.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export interface ProductSummary {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
description: string | null
|
||||
repo_url: string | null
|
||||
definition_of_done: string
|
||||
auto_pr: boolean
|
||||
}
|
||||
|
||||
interface ProductsStore {
|
||||
products: ProductSummary[]
|
||||
setProducts: (products: ProductSummary[]) => void
|
||||
addProduct: (product: ProductSummary) => void
|
||||
updateProduct: (id: string, patch: Partial<ProductSummary>) => void
|
||||
}
|
||||
|
||||
export const useProductsStore = create<ProductsStore>((set) => ({
|
||||
products: [],
|
||||
setProducts: (products) => set({ products }),
|
||||
addProduct: (product) =>
|
||||
set((state) => ({ products: [...state.products, product] })),
|
||||
updateProduct: (id, patch) =>
|
||||
set((state) => ({
|
||||
products: state.products.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
})),
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue