From 00af55972680daf40bb2885aa9d26fcfdefad7a4 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Fri, 15 May 2026 07:28:36 +0200 Subject: [PATCH] Sprint: ideeen aanpassen (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor: extraheer MultiFilterPills naar backlog-filter-popover Co-Authored-By: Claude Sonnet 4.6 * 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 * feat(ideas): vervang inline statuschips door IdeasFilterPopover met user-settings persistentie Co-Authored-By: Claude Sonnet 4.6 * test(ideas): voeg componenttests toe voor IdeasFilterPopover en persistentie Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * feat(ideas): stuur activeProductId mee bij snel idee aanmaken Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * refactor(notifications): gebruik question.options?.length als conditie Gebruik de kortere optional chaining variant consistent, conform plan. Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .../components/dialogs/answer-modal.test.tsx | 104 +++++++ __tests__/components/ideas/idea-list.test.tsx | 277 ++++++++++++++++++ __tests__/lib/user-settings.test.ts | 11 + app/(app)/ideas/page.tsx | 11 + components/ideas/idea-list.tsx | 63 ++-- components/ideas/ideas-filter-popover.tsx | 69 +++++ components/jobs/jobs-column.tsx | 54 +--- components/notifications/answer-modal.tsx | 65 ++-- components/shared/backlog-filter-popover.tsx | 53 ++++ docs/specs/dialogs/answer-modal.md | 6 +- lib/user-settings.ts | 8 + 11 files changed, 601 insertions(+), 120 deletions(-) create mode 100644 __tests__/components/dialogs/answer-modal.test.tsx create mode 100644 __tests__/components/ideas/idea-list.test.tsx create mode 100644 components/ideas/ideas-filter-popover.tsx diff --git a/__tests__/components/dialogs/answer-modal.test.tsx b/__tests__/components/dialogs/answer-modal.test.tsx new file mode 100644 index 0000000..26aad0f --- /dev/null +++ b/__tests__/components/dialogs/answer-modal.test.tsx @@ -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 }) => ( + {children} + ), +})) + +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 +const mockToast = toast as unknown as { + success: ReturnType + error: ReturnType +} + +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() + 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() + + 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() + + 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() + 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() + 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( + , + ) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/__tests__/components/ideas/idea-list.test.tsx b/__tests__/components/ideas/idea-list.test.tsx new file mode 100644 index 0000000..0e0a351 --- /dev/null +++ b/__tests__/components/ideas/idea-list.test.tsx @@ -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: () =>
, +})) + +// --- 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', + }) + }) + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index d62648a..2e694d7 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -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: { diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx index 1b2c45d..1c4fd5e 100644 --- a/app/(app)/ideas/page.tsx +++ b/app/(app)/ideas/page.tsx @@ -32,6 +32,16 @@ export default async function IdeasPage() { 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 (
@@ -45,6 +55,7 @@ export default async function IdeasPage() { ideas={ideas.map((i) => ideaToDto(i))} products={products} isDemo={session.isDemo ?? false} + activeProductId={activeProductId} />
) diff --git a/components/ideas/idea-list.tsx b/components/ideas/idea-list.tsx index 871d72d..546e54d 100644 --- a/components/ideas/idea-list.tsx +++ b/components/ideas/idea-list.tsx @@ -12,6 +12,10 @@ import { useMemo, useState, useTransition } from 'react' import { useRouter } from 'next/navigation' import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react' 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 { Button } from '@/components/ui/button' @@ -61,6 +65,7 @@ interface IdeaListProps { ideas: IdeaDto[] products: ProductOption[] isDemo: boolean + activeProductId: string | null } 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 [isPending, startTransition] = useTransition() // Filter state const [search, setSearch] = useState('') const [productFilter, setProductFilter] = useState('all') - const [statusFilter, setStatusFilter] = useState>(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 const [sortKey, setSortKey] = useState('code') @@ -132,7 +141,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { const [showCreate, setShowCreate] = useState(false) const [newTitle, setNewTitle] = useState('') const [newDescription, setNewDescription] = useState('') - const [newProductId, setNewProductId] = useState('') + const [newProductId, setNewProductId] = useState(activeProductId ?? '') // Quick-idea form state const [showQuick, setShowQuick] = useState(false) @@ -197,12 +206,14 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { } function toggleStatus(s: IdeaStatusApi) { - setStatusFilter((prev) => { - const next = new Set(prev) - if (next.has(s)) next.delete(s) - else next.add(s) - return next - }) + const next = filterStatuses.includes(s) + ? filterStatuses.filter((v) => v !== s) + : [...filterStatuses, s] + void setPref(['views', 'ideasList', 'filterStatuses'], next) + } + + function clearStatusFilter() { + void setPref(['views', 'ideasList', 'filterStatuses'], []) } function handleCreate() { @@ -225,7 +236,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { toast.success(`Idee aangemaakt (${r.data?.code})`) setNewTitle('') setNewDescription('') - setNewProductId('') + setNewProductId(activeProductId ?? '') setShowCreate(false) router.refresh() }) @@ -239,7 +250,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { return } 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) { toast.error(r.error) return @@ -289,6 +300,15 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { ))}
+
- {/* Status-chips als multi-select filter */} -
- {STATUS_FILTERS.map((s) => { - const active = statusFilter.has(s.value) - return ( - - ) - })} -
- {/* Snel idee form — geen product-dropdown */} {showQuick && (
diff --git a/components/ideas/ideas-filter-popover.tsx b/components/ideas/ideas-filter-popover.tsx new file mode 100644 index 0000000..f3d0c78 --- /dev/null +++ b/components/ideas/ideas-filter-popover.tsx @@ -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 + onToggle: (v: IdeaStatusApi) => void + onClear: () => void + activeFilterCount: number +} + +export function IdeasFilterPopover({ + open, + onOpenChange, + statusOptions, + selected, + onToggle, + onClear, + activeFilterCount, +}: IdeasFilterPopoverProps) { + return ( + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + +
+ +
+
+
+ ) +} diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index ffde6c5..b4b53d7 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import JobCard from './job-card' 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 { useUserSettingsStore } from '@/stores/user-settings/store' 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(KIND_OPTIONS.map((o) => o.value)) -function MultiFilterPills({ - label, - options, - selected, - onToggle, - onClear, -}: { - label: string - options: Array<{ value: T; label: string }> - selected: Set - onToggle: (v: T) => void - onClear: () => void -}) { - const allActive = selected.size === 0 - return ( -
-

{label}

-
- - {options.map((opt) => { - const active = selected.has(opt.value) - return ( - - ) - })} -
-
- ) -} - interface JobsColumnProps { title: string jobs: JobWithRelations[] diff --git a/components/notifications/answer-modal.tsx b/components/notifications/answer-modal.tsx index 8473e32..8a8d05b 100644 --- a/components/notifications/answer-modal.tsx +++ b/components/notifications/answer-modal.tsx @@ -115,7 +115,7 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { {question.question}
- {question.options && question.options.length > 0 ? ( + {question.options?.length ? (

Kies een van de opties:

@@ -134,28 +134,31 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { ))}
- ) : ( -
-