* feat(user-settings): voeg IdeasListPrefs schema toe met filterStatuses Nieuw IdeasListPrefs-subschema met filterStatuses (array van IdeaStatusApi-waarden), ingehangen als views.ideasList in ViewsPrefs. Testdekking voor geldig, ongeldig en leeg filterStatuses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: extraheer MultiFilterPills naar backlog-filter-popover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): voeg IdeasFilterPopover component toe Nieuwe client-component met multi-select statusfilter popover voor het Ideeënscherm; hergebruikt MultiFilterPills uit backlog-filter-popover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): vervang inline statuschips door IdeasFilterPopover met user-settings persistentie Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ideas): voeg componenttests toe voor IdeasFilterPopover en persistentie Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): voeg activeProductId-prop toe aan IdeaList IdeaListProps uitgebreid met activeProductId: string | null. Create-form initialiseert en reset naar het actieve product na aanmaken. Tests en page.tsx bijgewerkt (page.tsx krijgt echte waarde in volgende taak). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): resolve active_product_id en geef door aan IdeaList Haalt active_product_id op via prisma.user.findUnique en resolveert het tegen de al opgehaalde toegankelijke productenlijst (AC4). Geeft het resultaat als prop door aan IdeaList in plaats van hardcoded null. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): stuur activeProductId mee bij snel idee aanmaken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ideas): voeg component-tests toe voor activeProductId-voorvulling AC1: "Nieuw idee"-select voorgevuld met activeProductId AC2: "Nieuw idee"-select leeg bij activeProductId=null AC3: "Snel idee" stuurt product_id=activeProductId mee bij createIdeaAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(notifications): toon textarea altijd in answer-modal naast opties Vervang opties-XOR-textarea door twee onafhankelijke blokken: opties alleen wanneer aanwezig, vrij tekstveld altijd zichtbaar. Bij opties een visuele scheiding (border-t) en label 'Of typ een eigen antwoord'. Verstuur-knop nu altijd in footer zichtbaar (was verborgen bij opties). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(notifications): gebruik question.options?.length als conditie Gebruik de kortere optional chaining variant consistent, conform plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(notifications): voeg component-tests toe voor AnswerModal Dekt: optieknoppen + textarea + Verstuur zichtbaar met opties, submit via optieknop, submit via vrij tekstveld, disabled Verstuur bij leeg veld, en demo-modus (textarea + Verstuur disabled). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(notifications): werk answer-modal spec bij voor vrije tekstveld naast opties Beschrijft dat textarea + Verstuur altijd zichtbaar zijn in multiple-choice mode. Corrigeert de Cmd/Ctrl+Enter-bullet: shortcut werkt nu ook daar. Bijgewerkt naar 2026-05-15. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
// @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: () => <div data-testid="idea-row-actions" />,
|
|
}))
|
|
|
|
// --- 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
|
|
}) => (
|
|
<PopoverCtx.Provider value={{ open: open ?? false, onOpenChange: onOpenChange ?? (() => {}) }}>
|
|
{children}
|
|
</PopoverCtx.Provider>
|
|
),
|
|
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 ? <div data-testid="popover-content">{children}</div> : 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> = {}): 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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
// 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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
// 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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
// 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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
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(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
|
|
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(
|
|
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
|
|
)
|
|
|
|
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(
|
|
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId={null} />
|
|
)
|
|
|
|
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(
|
|
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
|
|
)
|
|
|
|
// 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',
|
|
})
|
|
})
|
|
})
|
|
})
|