diff --git a/__tests__/actions/products.test.ts b/__tests__/actions/products.test.ts new file mode 100644 index 0000000..b2cc766 --- /dev/null +++ b/__tests__/actions/products.test.ts @@ -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 + +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) => { + 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() + }) +}) diff --git a/__tests__/components/dialogs/product-dialog.test.tsx b/__tests__/components/dialogs/product-dialog.test.tsx new file mode 100644 index 0000000..bbbb51c --- /dev/null +++ b/__tests__/components/dialogs/product-dialog.test.tsx @@ -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 +const mockUpdate = updateProductAction as ReturnType +const mockToast = toast as unknown 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/actions/products.ts b/actions/products.ts index 892569f..a43f492 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -9,6 +9,7 @@ import { prisma } from '@/lib/prisma' 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' const productSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), @@ -28,11 +29,122 @@ 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 } + async function getSession() { return getIronSession(await cookies(), sessionOptions) } -export async function createProductAction(_prevState: unknown, formData: FormData) { +// 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' } + + const parsed = productInput.safeParse(data) + if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + + const code = normalizeCode(parsed.data.code) + if (code !== null && !isValidCode(code)) { + return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + } + + if (code) { + const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) + if (dup) return { error: 'Code is al in gebruik' } + } + + const userId = session.userId + const product = await prisma.$transaction(async (tx) => { + const p = await tx.product.create({ + data: { + user_id: userId, + name: parsed.data.name, + code: code ?? null, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url ?? null, + definition_of_done: parsed.data.definition_of_done ?? '', + auto_pr: parsed.data.auto_pr, + }, + }) + await tx.productMember.create({ data: { product_id: p.id, user_id: userId } }) + return p + }) + + revalidatePath('/products') + revalidatePath('/dashboard') + return { success: true, productId: product.id } +} + +// Data-object API used by ProductDialog +export async function updateProductAction(id: string, data: ProductInput): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = productInput.safeParse(data) + if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + + const product = await prisma.product.findFirst({ + where: { id, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Product niet gevonden of geen toegang' } + + const code = normalizeCode(parsed.data.code) + if (code !== null && !isValidCode(code)) { + return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + } + + const userId = session.userId + + await prisma.product.update({ + where: { id }, + data: { + name: parsed.data.name, + code: code ?? null, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url ?? null, + ...(parsed.data.definition_of_done !== undefined && { + definition_of_done: parsed.data.definition_of_done, + }), + auto_pr: parsed.data.auto_pr, + }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'product_updated', + product_id: id, + user_id: userId, + })}::text) + ` + + revalidatePath(`/products/${id}`) + revalidatePath('/dashboard') + return { success: true } +} + +// FormData-based actions for existing ProductForm components +export async function createProductFormAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } @@ -78,7 +190,7 @@ export async function createProductAction(_prevState: unknown, formData: FormDat redirect(`/products/${product.id}`) } -export async function updateProductAction(_prevState: unknown, formData: FormData) { +export async function updateProductFormAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 9497e54..95a84d4 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -4,8 +4,8 @@ import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import Link from 'next/link' -import { Button } from '@/components/ui/button' import { ProductList } from '@/components/dashboard/product-list' +import { NewProductButton } from '@/components/dashboard/new-product-button' interface Props { searchParams: Promise<{ archived?: string }> @@ -43,9 +43,7 @@ export default async function DashboardPage({ searchParams }: Props) { )} - {!session.isDemo && !showArchived && ( - - )} + {!session.isDemo && !showArchived && } )} + {!isDemo && product.user_id === session.userId && ( + + )} (await cookies(), sessionOptions) @@ -12,7 +12,7 @@ export default async function NewProductPage() { return (

Nieuw product

- +
) } diff --git a/components/dashboard/new-product-button.tsx b/components/dashboard/new-product-button.tsx new file mode 100644 index 0000000..cf3e36a --- /dev/null +++ b/components/dashboard/new-product-button.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { ProductDialog } from '@/components/dialogs/product-dialog' + +export function NewProductButton() { + const [open, setOpen] = useState(false) + const router = useRouter() + + return ( + <> + + router.push(`/products/${id}`)} + /> + + ) +} 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}

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