// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import React from 'react' // --- Navigation mock --- vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), })) // --- Actions mocks --- vi.mock('@/actions/ideas', () => ({ createIdeaAction: vi.fn(), archiveIdeaAction: vi.fn(), })) vi.mock('@/actions/user-settings', () => ({ updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), })) // --- Sonner mock --- vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })) // --- IdeaRowActions mock (complex component with many deps) --- vi.mock('@/components/ideas/idea-row-actions', () => ({ IdeaRowActions: () =>
, })) // --- DemoTooltip mock --- vi.mock('@/components/shared/demo-tooltip', () => ({ DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })) // --- Popover mock — controlled via open prop --- vi.mock('@/components/ui/popover', () => { const PopoverCtx = React.createContext<{ open: boolean onOpenChange: (v: boolean) => void }>({ open: false, onOpenChange: () => {} }) return { Popover: ({ children, open, onOpenChange, }: { children: React.ReactNode open?: boolean onOpenChange?: (v: boolean) => void }) => ( {}) }}> {children} ), PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => { const { open, onOpenChange } = React.useContext(PopoverCtx) return React.cloneElement(renderEl, { onClick: (e: React.MouseEvent) => { onOpenChange(!open) renderEl.props.onClick?.(e) }, }) }, PopoverContent: ({ children }: { children: React.ReactNode }) => { const { open } = React.useContext(PopoverCtx) return open ?
{children}
: null }, } }) // Import after mocks import { useUserSettingsStore } from '@/stores/user-settings/store' import { IdeaList } from '@/components/ideas/idea-list' import { createIdeaAction } from '@/actions/ideas' import type { IdeaDto } from '@/lib/idea-dto' const PRODUCTS = [ { id: 'prod-1', name: 'Product A', repo_url: null }, // repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix) { id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' }, ] // Minimal IdeaDto factory function makeIdea(overrides: Partial = {}): IdeaDto { return { id: 'idea-1', code: 'ID-1', title: 'Test Idee', description: null, status: 'draft', product_id: null, product: null, pbi_id: null, pbi: null, secondary_products: [], archived: false, has_grill_md: false, has_plan_md: false, created_at: '2024-01-01T00:00:00.000Z', updated_at: '2024-01-01T00:00:00.000Z', ...overrides, } } const IDEAS: IdeaDto[] = [ makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }), makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }), makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }), ] beforeEach(() => { vi.clearAllMocks() useUserSettingsStore.getState().hydrate({}, false) }) describe('IdeaList — filterpopover', () => { it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => { render() // Filters-knop aanwezig expect(screen.getByText('Filters')).toBeInTheDocument() // Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen // (anders was de oude inline chip-rij er nog) expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument() }) it('klik op "Filters" opent de popover en toont 11 statusopties', () => { render() // Popover nog niet open: content niet zichtbaar expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() fireEvent.click(screen.getByText('Filters')) // Content verschijnt expect(screen.getByTestId('popover-content')).toBeInTheDocument() // 11 statusopties + "Alle" = 12 buttons in de popover // Controleer specifiek de 11 status-labels const statusLabels = [ 'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar', 'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt', 'Beoordeling mislukt', 'Plan beoordeeld', ] for (const label of statusLabels) { expect(screen.getByRole('button', { name: label })).toBeInTheDocument() } }) it('klik op een statuschip schrijft de status naar de store', () => { render() fireEvent.click(screen.getByText('Filters')) fireEvent.click(screen.getByRole('button', { name: 'Concept' })) const stored = useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses expect(stored).toContain('draft') }) it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => { useUserSettingsStore .getState() .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) render() // Trigger toont het actieve filteraantal expect(screen.getByText('Filters (1)')).toBeInTheDocument() // Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd expect(screen.getByText('Idee Concept')).toBeInTheDocument() expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument() expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument() }) it('"Wis filters" is disabled wanneer geen filter actief is', () => { render() fireEvent.click(screen.getByText('Filters')) const wisButton = screen.getByRole('button', { name: 'Wis filters' }) expect(wisButton).toBeDisabled() }) it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => { useUserSettingsStore .getState() .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) render() fireEvent.click(screen.getByText('Filters (1)')) const wisButton = screen.getByRole('button', { name: 'Wis filters' }) expect(wisButton).not.toBeDisabled() fireEvent.click(wisButton) const stored = useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses expect(stored).toEqual([]) }) }) describe('IdeaList — activeProductId voorvullen', () => { // Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud. // getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen // met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in // jsdom soms afwijkt van wat we verwachten. function clickButton(label: string) { const btn = Array.from(document.querySelectorAll('button')).find( (b) => b.textContent?.trim().includes(label) ) if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`) fireEvent.click(btn) } it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => { render( ) clickButton('Nieuw idee') // Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2'). // De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is. const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B')) expect(createFormSelect).toHaveValue('prod-2') }) it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => { render( ) clickButton('Nieuw idee') // Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde ''). const createFormSelect = await waitFor(() => screen.getByDisplayValue('Geen product (kan later worden gekoppeld)') ) expect(createFormSelect).toHaveValue('') }) it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => { vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never) render( ) // Open "Snel idee"-formulier en wacht tot het verschijnt clickButton('Snel idee') await waitFor(() => screen.getByPlaceholderText('Titel *')) // Vul de verplichte titel in fireEvent.change(screen.getByPlaceholderText('Titel *'), { target: { value: 'Mijn snel idee' }, }) // Klik Opslaan — startTransition roept createIdeaAction synchroon aan clickButton('Opslaan') await waitFor(() => { expect(createIdeaAction).toHaveBeenCalledWith({ title: 'Mijn snel idee', description: null, product_id: 'prod-2', }) }) }) })