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:
parent
3d5c22382c
commit
00af559726
11 changed files with 601 additions and 120 deletions
104
__tests__/components/dialogs/answer-modal.test.tsx
Normal file
104
__tests__/components/dialogs/answer-modal.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
277
__tests__/components/ideas/idea-list.test.tsx
Normal file
277
__tests__/components/ideas/idea-list.test.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -108,6 +108,7 @@ describe('UserSettingsSchema', () => {
|
||||||
storyPanel: { sort: 'date' },
|
storyPanel: { sort: 'date' },
|
||||||
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
|
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
|
||||||
jobs: { timeFilter: '24h' },
|
jobs: { timeFilter: '24h' },
|
||||||
|
ideasList: { filterStatuses: ['draft', 'planned'] },
|
||||||
},
|
},
|
||||||
devTools: { debugMode: true },
|
devTools: { debugMode: true },
|
||||||
layout: {
|
layout: {
|
||||||
|
|
@ -185,6 +186,16 @@ describe('UserSettingsSchema', () => {
|
||||||
expect(result.success).toBe(false)
|
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', () => {
|
it('rejects unknown intent value', () => {
|
||||||
const result = UserSettingsSchema.safeParse({
|
const result = UserSettingsSchema.safeParse({
|
||||||
workflow: {
|
workflow: {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@ export default async function IdeasPage() {
|
||||||
select: { id: true, name: true, repo_url: true },
|
select: { id: true, name: true, repo_url: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { active_product_id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeProductId =
|
||||||
|
user?.active_product_id && products.some((p) => p.id === user.active_product_id)
|
||||||
|
? user.active_product_id
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl mx-auto w-full">
|
<div className="p-6 max-w-5xl mx-auto w-full">
|
||||||
<header className="mb-6 flex items-baseline justify-between">
|
<header className="mb-6 flex items-baseline justify-between">
|
||||||
|
|
@ -45,6 +55,7 @@ export default async function IdeasPage() {
|
||||||
ideas={ideas.map((i) => ideaToDto(i))}
|
ideas={ideas.map((i) => ideaToDto(i))}
|
||||||
products={products}
|
products={products}
|
||||||
isDemo={session.isDemo ?? false}
|
isDemo={session.isDemo ?? false}
|
||||||
|
activeProductId={activeProductId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import { useMemo, useState, useTransition } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { IdeasFilterPopover } from '@/components/ideas/ideas-filter-popover'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -61,6 +65,7 @@ interface IdeaListProps {
|
||||||
ideas: IdeaDto[]
|
ideas: IdeaDto[]
|
||||||
products: ProductOption[]
|
products: ProductOption[]
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
activeProductId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [
|
const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [
|
||||||
|
|
@ -115,14 +120,18 @@ function SortHeader({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
export function IdeaList({ ideas, products, isDemo, activeProductId }: IdeaListProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [productFilter, setProductFilter] = useState<string>('all')
|
const [productFilter, setProductFilter] = useState<string>('all')
|
||||||
const [statusFilter, setStatusFilter] = useState<Set<IdeaStatusApi>>(new Set())
|
const filterStatuses = useUserSettingsStore(useShallow(
|
||||||
|
(s) => s.entities.settings.views?.ideasList?.filterStatuses ?? []))
|
||||||
|
const setPref = useUserSettingsStore((s) => s.setPref)
|
||||||
|
const statusFilter = useMemo(() => new Set(filterStatuses), [filterStatuses])
|
||||||
|
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('code')
|
const [sortKey, setSortKey] = useState<SortKey>('code')
|
||||||
|
|
@ -132,7 +141,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [newDescription, setNewDescription] = useState('')
|
const [newDescription, setNewDescription] = useState('')
|
||||||
const [newProductId, setNewProductId] = useState<string>('')
|
const [newProductId, setNewProductId] = useState<string>(activeProductId ?? '')
|
||||||
|
|
||||||
// Quick-idea form state
|
// Quick-idea form state
|
||||||
const [showQuick, setShowQuick] = useState(false)
|
const [showQuick, setShowQuick] = useState(false)
|
||||||
|
|
@ -197,12 +206,14 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStatus(s: IdeaStatusApi) {
|
function toggleStatus(s: IdeaStatusApi) {
|
||||||
setStatusFilter((prev) => {
|
const next = filterStatuses.includes(s)
|
||||||
const next = new Set(prev)
|
? filterStatuses.filter((v) => v !== s)
|
||||||
if (next.has(s)) next.delete(s)
|
: [...filterStatuses, s]
|
||||||
else next.add(s)
|
void setPref(['views', 'ideasList', 'filterStatuses'], next)
|
||||||
return next
|
}
|
||||||
})
|
|
||||||
|
function clearStatusFilter() {
|
||||||
|
void setPref(['views', 'ideasList', 'filterStatuses'], [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
|
|
@ -225,7 +236,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
toast.success(`Idee aangemaakt (${r.data?.code})`)
|
toast.success(`Idee aangemaakt (${r.data?.code})`)
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
setNewDescription('')
|
setNewDescription('')
|
||||||
setNewProductId('')
|
setNewProductId(activeProductId ?? '')
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
})
|
})
|
||||||
|
|
@ -239,7 +250,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: null })
|
const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: activeProductId })
|
||||||
if ('error' in r) {
|
if ('error' in r) {
|
||||||
toast.error(r.error)
|
toast.error(r.error)
|
||||||
return
|
return
|
||||||
|
|
@ -289,6 +300,15 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<IdeasFilterPopover
|
||||||
|
open={filterPopoverOpen}
|
||||||
|
onOpenChange={setFilterPopoverOpen}
|
||||||
|
statusOptions={STATUS_FILTERS}
|
||||||
|
selected={statusFilter}
|
||||||
|
onToggle={toggleStatus}
|
||||||
|
onClear={clearStatusFilter}
|
||||||
|
activeFilterCount={statusFilter.size}
|
||||||
|
/>
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -313,27 +333,6 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status-chips als multi-select filter */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{STATUS_FILTERS.map((s) => {
|
|
||||||
const active = statusFilter.has(s.value)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleStatus(s.value)}
|
|
||||||
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-primary text-on-primary border-primary'
|
|
||||||
: 'bg-background text-muted-foreground border-input hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Snel idee form — geen product-dropdown */}
|
{/* Snel idee form — geen product-dropdown */}
|
||||||
{showQuick && (
|
{showQuick && (
|
||||||
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
|
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
|
||||||
|
|
|
||||||
69
components/ideas/ideas-filter-popover.tsx
Normal file
69
components/ideas/ideas-filter-popover.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { MultiFilterPills } from '@/components/shared/backlog-filter-popover'
|
||||||
|
import { debugProps } from '@/lib/debug'
|
||||||
|
import type { IdeaStatusApi } from '@/lib/idea-status'
|
||||||
|
|
||||||
|
interface IdeasFilterPopoverProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
statusOptions: Array<{ value: IdeaStatusApi; label: string }>
|
||||||
|
selected: Set<IdeaStatusApi>
|
||||||
|
onToggle: (v: IdeaStatusApi) => void
|
||||||
|
onClear: () => void
|
||||||
|
activeFilterCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeasFilterPopover({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
statusOptions,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onClear,
|
||||||
|
activeFilterCount,
|
||||||
|
}: IdeasFilterPopoverProps) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
{...debugProps('ideas-filter-popover__trigger')}
|
||||||
|
>
|
||||||
|
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-72 space-y-4"
|
||||||
|
{...debugProps('ideas-filter-popover', 'IdeasFilterPopover', 'components/ideas/ideas-filter-popover.tsx')}
|
||||||
|
>
|
||||||
|
<MultiFilterPills
|
||||||
|
label="Status"
|
||||||
|
options={statusOptions}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClear={onClear}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end pt-1 border-t border-border">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={activeFilterCount === 0}
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Wis filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||||
import JobCard from './job-card'
|
import JobCard from './job-card'
|
||||||
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
||||||
|
import { MultiFilterPills } from '@/components/shared/backlog-filter-popover'
|
||||||
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter'
|
import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter'
|
||||||
|
|
@ -34,59 +35,6 @@ const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [
|
||||||
|
|
||||||
const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
|
const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
|
||||||
|
|
||||||
function MultiFilterPills<T extends string>({
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
selected,
|
|
||||||
onToggle,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
options: Array<{ value: T; label: string }>
|
|
||||||
selected: Set<T>
|
|
||||||
onToggle: (v: T) => void
|
|
||||||
onClear: () => void
|
|
||||||
}) {
|
|
||||||
const allActive = selected.size === 0
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClear}
|
|
||||||
className={cn(
|
|
||||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
|
||||||
allActive
|
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
|
||||||
: 'bg-transparent border-border hover:bg-surface-container'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
{options.map((opt) => {
|
|
||||||
const active = selected.has(opt.value)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggle(opt.value)}
|
|
||||||
className={cn(
|
|
||||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
|
||||||
active
|
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
|
||||||
: 'bg-transparent border-border hover:bg-surface-container'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JobsColumnProps {
|
interface JobsColumnProps {
|
||||||
title: string
|
title: string
|
||||||
jobs: JobWithRelations[]
|
jobs: JobWithRelations[]
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
||||||
{question.question}
|
{question.question}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{question.options && question.options.length > 0 ? (
|
{question.options?.length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
|
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -134,28 +134,31 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="space-y-1">
|
|
||||||
<Textarea
|
<div className={question.options?.length ? 'space-y-1 border-t pt-4' : 'space-y-1'}>
|
||||||
value={answer}
|
{question.options?.length ? (
|
||||||
onChange={(e) => setAnswer(e.target.value)}
|
<p className="text-muted-foreground text-xs">Of typ een eigen antwoord</p>
|
||||||
placeholder="Typ je antwoord…"
|
) : null}
|
||||||
rows={5}
|
<Textarea
|
||||||
maxLength={ANSWER_MAX_CHARS}
|
value={answer}
|
||||||
disabled={isDemo}
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
aria-label="Antwoord op Claude's vraag"
|
placeholder="Typ je antwoord…"
|
||||||
/>
|
rows={5}
|
||||||
<div
|
maxLength={ANSWER_MAX_CHARS}
|
||||||
className={
|
disabled={isDemo}
|
||||||
tooLong
|
aria-label="Antwoord op Claude's vraag"
|
||||||
? 'text-error text-right text-xs'
|
/>
|
||||||
: 'text-muted-foreground text-right text-xs'
|
<div
|
||||||
}
|
className={
|
||||||
>
|
tooLong
|
||||||
{charsLeft} tekens over
|
? 'text-error text-right text-xs'
|
||||||
</div>
|
: 'text-muted-foreground text-right text-xs'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{charsLeft} tekens over
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={entityDialogFooterClasses} data-debug-id="answer-modal__submit">
|
<div className={entityDialogFooterClasses} data-debug-id="answer-modal__submit">
|
||||||
|
|
@ -163,16 +166,14 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
||||||
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
||||||
Annuleren
|
Annuleren
|
||||||
</Button>
|
</Button>
|
||||||
{(!question.options || question.options.length === 0) && (
|
<DemoTooltip show={isDemo}>
|
||||||
<DemoTooltip show={isDemo}>
|
<Button
|
||||||
<Button
|
onClick={() => submit(answer)}
|
||||||
onClick={() => submit(answer)}
|
disabled={submitDisabled}
|
||||||
disabled={submitDisabled}
|
>
|
||||||
>
|
{pending ? 'Bezig…' : 'Verstuur'}
|
||||||
{pending ? 'Bezig…' : 'Verstuur'}
|
</Button>
|
||||||
</Button>
|
</DemoTooltip>
|
||||||
</DemoTooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,59 @@ export function FilterPills<T extends string | number>({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MultiFilterPills<T extends string>({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
options: Array<{ value: T; label: string }>
|
||||||
|
selected: Set<T>
|
||||||
|
onToggle: (v: T) => void
|
||||||
|
onClear: () => void
|
||||||
|
}) {
|
||||||
|
const allActive = selected.size === 0
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||||
|
allActive
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-transparent border-border hover:bg-surface-container'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = selected.has(opt.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-transparent border-border hover:bg-surface-container'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface BacklogFilterPopoverProps<S extends string, So extends string> {
|
interface BacklogFilterPopoverProps<S extends string, So extends string> {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ title: "AnswerModal Profiel"
|
||||||
status: active
|
status: active
|
||||||
audience: [ai-agent, contributor]
|
audience: [ai-agent, contributor]
|
||||||
language: nl
|
language: nl
|
||||||
last_updated: 2026-05-04
|
last_updated: 2026-05-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# AnswerModal Profiel
|
# AnswerModal Profiel
|
||||||
|
|
@ -47,7 +47,7 @@ Gebruikt `entityDialogContentClasses` (§4 spec). Body bevat naast de textarea o
|
||||||
|
|
||||||
### Multiple-choice mode
|
### Multiple-choice mode
|
||||||
|
|
||||||
Als `question.options` niet leeg is, wordt de textarea vervangen door een lijst van knoppen. Klikken op een knop submit direct met die waarde. De submit-knop in de footer wordt dan verborgen (alleen Annuleren blijft).
|
Als `question.options` niet leeg is, worden de opties getoond als een lijst van knoppen. Klikken op een knop submit direct met die waarde. Het vrije tekstveld en de Verstuur-knop blijven altijd zichtbaar — ook in multiple-choice mode. Zo kan de gebruiker naast de vaste opties ook een eigen antwoord typen en versturen.
|
||||||
|
|
||||||
### Optimistic remove
|
### Optimistic remove
|
||||||
|
|
||||||
|
|
@ -64,5 +64,5 @@ Action geeft alleen `{ ok, error: string }` terug — geen 422-fieldErrors omdat
|
||||||
## Bewust NIET in v1
|
## Bewust NIET in v1
|
||||||
|
|
||||||
- ❌ **Markdown rendering** — antwoord wordt als plain text doorgegeven; Claude leest 'm direct als context.
|
- ❌ **Markdown rendering** — antwoord wordt als plain text doorgegeven; Claude leest 'm direct als context.
|
||||||
- ❌ **Cmd/Ctrl+Enter shortcut** — werkt wél voor de textarea-mode (via `useDialogSubmitShortcut`); voor multiple-choice mode is er geen submit om te triggeren.
|
- ✅ **Cmd/Ctrl+Enter shortcut** — werkt via `useDialogSubmitShortcut` in zowel textarea-mode als multiple-choice mode (het vrije tekstveld is altijd aanwezig).
|
||||||
- ❌ **Bulk-answer** — één vraag tegelijk per dialog.
|
- ❌ **Bulk-answer** — één vraag tegelijk per dialog.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { JOBS_TIME_FILTER_VALUES } from '@/lib/jobs-time-filter'
|
import { JOBS_TIME_FILTER_VALUES } from '@/lib/jobs-time-filter'
|
||||||
|
import { IDEA_STATUS_API_VALUES, type IdeaStatusApi } from '@/lib/idea-status'
|
||||||
|
|
||||||
const PriorityFilter = z.union([
|
const PriorityFilter = z.union([
|
||||||
z.number().int().min(1).max(4),
|
z.number().int().min(1).max(4),
|
||||||
|
|
@ -37,12 +38,19 @@ const JobsViewPrefs = z.object({
|
||||||
timeFilter: z.enum(JOBS_TIME_FILTER_VALUES).optional(),
|
timeFilter: z.enum(JOBS_TIME_FILTER_VALUES).optional(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
|
const IdeasListPrefs = z.object({
|
||||||
|
filterStatuses: z.array(
|
||||||
|
z.enum(IDEA_STATUS_API_VALUES as [IdeaStatusApi, ...IdeaStatusApi[]])
|
||||||
|
).optional(),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
const ViewsPrefs = z.object({
|
const ViewsPrefs = z.object({
|
||||||
sprintBacklog: SprintBacklogPrefs.optional(),
|
sprintBacklog: SprintBacklogPrefs.optional(),
|
||||||
pbiList: PbiListPrefs.optional(),
|
pbiList: PbiListPrefs.optional(),
|
||||||
storyPanel: StoryPanelPrefs.optional(),
|
storyPanel: StoryPanelPrefs.optional(),
|
||||||
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
|
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
|
||||||
jobs: JobsViewPrefs.optional(),
|
jobs: JobsViewPrefs.optional(),
|
||||||
|
ideasList: IdeasListPrefs.optional(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
const DevToolsPrefs = z.object({
|
const DevToolsPrefs = z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue