Sprint: ideeen aanpassen (#211)

* 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>
This commit is contained in:
Janpeter Visser 2026-05-15 07:28:36 +02:00 committed by GitHub
parent 3d5c22382c
commit 00af559726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 601 additions and 120 deletions

View file

@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('@/actions/questions', () => ({
answerQuestion: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/notifications-store', () => ({
useNotificationsStore: {
getState: () => ({ remove: vi.fn() }),
},
}))
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}))
import { AnswerModal } from '@/components/notifications/answer-modal'
import { answerQuestion } from '@/actions/questions'
import { toast } from 'sonner'
import type { NotificationQuestion } from '@/stores/notifications-store'
const mockAnswerQuestion = answerQuestion as ReturnType<typeof vi.fn>
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
const QUESTION: NotificationQuestion = {
kind: 'idea',
id: 'q-1',
product_id: 'prod-1',
idea_id: 'idea-1',
idea_code: 'IDEA-42',
idea_title: 'Mijn Idee',
question: 'Wat denk jij?',
options: ['Optie A', 'Optie B'],
created_at: '2026-01-01T00:00:00Z',
expires_at: '2026-12-31T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('AnswerModal — met opties', () => {
it('toont optieknoppen, textarea en Verstuur-knop', () => {
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy()
expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy()
})
it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => {
mockAnswerQuestion.mockResolvedValue({ ok: true })
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Optie A' }))
await waitFor(() => {
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A')
})
})
it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => {
mockAnswerQuestion.mockResolvedValue({ ok: true })
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), {
target: { value: 'Mijn eigen antwoord' },
})
fireEvent.click(screen.getByRole('button', { name: 'Verstuur' }))
await waitFor(() => {
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord')
})
})
it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => {
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
})
})
describe('AnswerModal — demo-modus', () => {
it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => {
render(<AnswerModal question={QUESTION} isDemo={true} onClose={vi.fn()} />)
expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
})
})
describe('AnswerModal — geen vraag', () => {
it('rendert niets wanneer question null is', () => {
const { container } = render(
<AnswerModal question={null} isDemo={false} onClose={vi.fn()} />,
)
expect(container.firstChild).toBeNull()
})
})

View file

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

View file

@ -108,6 +108,7 @@ describe('UserSettingsSchema', () => {
storyPanel: { sort: 'date' },
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
jobs: { timeFilter: '24h' },
ideasList: { filterStatuses: ['draft', 'planned'] },
},
devTools: { debugMode: true },
layout: {
@ -185,6 +186,16 @@ describe('UserSettingsSchema', () => {
expect(result.success).toBe(false)
})
it('rejects an invalid ideasList.filterStatuses value', () => {
const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: ['BOGUS'] } } })
expect(result.success).toBe(false)
})
it('accepts an empty ideasList.filterStatuses array', () => {
const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: [] } } })
expect(result.success).toBe(true)
})
it('rejects unknown intent value', () => {
const result = UserSettingsSchema.safeParse({
workflow: {