ProductDialog: create + edit met alle velden (#68)

* feat(ST-?): createProductAction + updateProductAction (data-object API)

Voegt data-object-gebaseerde createProductAction(data) en
updateProductAction(id, data) toe aan actions/products.ts voor gebruik
door ProductDialog. Bevat Zod-validatie (incl. github-regex op repo_url),
productAccessFilter voor update, pg_notify bij update, en productMember-
aanleg bij create. FormData-varianten hernoemd naar ...FormAction; callers
bijgewerkt. 9 nieuwe tests groen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(ST-?): UI triggers voor ProductDialog op dashboard en product-detail

Voegt NewProductButton toe op het dashboard (vervangt de /products/new
link) en EditProductButton op de product-detail pagina. Bewerken-knop
is alleen zichtbaar voor de product-eigenaar en verborgen in demo-modus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): cast toast via unknown to satisfy strict TS

`toast as { success, error }` direct-cast faalt omdat sonner's toast een
callable + properties is. TS2352. Cast via unknown lost het op zonder
gedrag te wijzigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-03 17:56:33 +02:00 committed by GitHub
parent 60e2b62bbe
commit 0ee03c6b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 777 additions and 10 deletions

View file

@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstProduct,
mockCreateProduct,
mockUpdateProduct,
mockCreateMember,
mockExecuteRaw,
mockTransaction,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockCreateProduct: vi.fn(),
mockUpdateProduct: vi.fn(),
mockCreateMember: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
mockTransaction: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({ OR: [{ user_id: 'user-1' }] }),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: mockFindFirstProduct, create: mockCreateProduct, update: mockUpdateProduct },
productMember: { create: mockCreateMember },
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
import { createProductAction, updateProductAction } from '@/actions/products'
import { getIronSession } from 'iron-session'
const mockSession = getIronSession as ReturnType<typeof vi.fn>
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const PRODUCT_ID = 'product-1'
const VALID_DATA = {
name: 'Test Product',
code: 'TP',
description: 'Een product',
repo_url: 'https://github.com/org/repo',
definition_of_done: 'Alles groen',
auto_pr: false,
}
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
mockSession.mockResolvedValue(SESSION_USER)
})
// =============================================================
// createProductAction
// =============================================================
describe('createProductAction', () => {
it('happy path: maakt product + member aan en retourneert productId', async () => {
mockFindFirstProduct.mockResolvedValue(null) // geen dubbele code
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({
product: {
create: vi.fn().mockResolvedValue({ id: PRODUCT_ID }),
},
productMember: {
create: vi.fn().mockResolvedValue({}),
},
})
})
const result = await createProductAction(VALID_DATA)
expect(result).toEqual({ success: true, productId: PRODUCT_ID })
})
it('demo-user → error', async () => {
mockSession.mockResolvedValue(SESSION_DEMO)
const result = await createProductAction(VALID_DATA)
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('ongeldige repo_url (niet github) → validatiefout', async () => {
const result = await createProductAction({ ...VALID_DATA, repo_url: 'https://gitlab.com/org/repo' })
expect(result).toMatchObject({ error: expect.any(String) })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('dubbele code → error', async () => {
mockFindFirstProduct.mockResolvedValue({ id: 'other-product' })
const result = await createProductAction(VALID_DATA)
expect(result).toMatchObject({ error: expect.stringContaining('gebruik') })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('naam ontbreekt → validatiefout', async () => {
const result = await createProductAction({ ...VALID_DATA, name: '' })
expect(result).toMatchObject({ error: expect.any(String) })
})
})
// =============================================================
// updateProductAction
// =============================================================
describe('updateProductAction', () => {
it('happy path: werkt product bij en stuurt pg_notify', async () => {
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockUpdateProduct.mockResolvedValue({ id: PRODUCT_ID })
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
expect(result).toEqual({ success: true })
expect(mockUpdateProduct).toHaveBeenCalled()
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
})
it('demo-user → error', async () => {
mockSession.mockResolvedValue(SESSION_DEMO)
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockUpdateProduct).not.toHaveBeenCalled()
})
it('geen toegang tot product → error', async () => {
mockFindFirstProduct.mockResolvedValue(null)
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
expect(result).toMatchObject({ error: expect.stringContaining('toegang') })
expect(mockUpdateProduct).not.toHaveBeenCalled()
})
it('ongeldige repo_url → validatiefout', async () => {
const result = await updateProductAction(PRODUCT_ID, { ...VALID_DATA, repo_url: 'https://bitbucket.org/x' })
expect(result).toMatchObject({ error: expect.any(String) })
expect(mockUpdateProduct).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,134 @@
// @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 unknown 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)
})
})