* 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>
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
DEFAULT_USER_SETTINGS,
|
|
UserSettingsSchema,
|
|
mergeSettings,
|
|
parseUserSettings,
|
|
type UserSettings,
|
|
} from '@/lib/user-settings'
|
|
|
|
describe('mergeSettings', () => {
|
|
it('returns the patch when previous is empty', () => {
|
|
const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } })
|
|
expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } })
|
|
})
|
|
|
|
it('preserves existing keys when patch only sets new ones', () => {
|
|
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
|
|
const result = mergeSettings(prev, {
|
|
views: { pbiList: { sort: 'date' } },
|
|
})
|
|
expect(result).toEqual({
|
|
views: {
|
|
sprintBacklog: { sort: 'code' },
|
|
pbiList: { sort: 'date' },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('merges nested objects without overwriting siblings', () => {
|
|
const prev: UserSettings = {
|
|
views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } },
|
|
}
|
|
const result = mergeSettings(prev, {
|
|
views: { sprintBacklog: { sort: 'priority' } },
|
|
})
|
|
expect(result).toEqual({
|
|
views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } },
|
|
})
|
|
})
|
|
|
|
it('replaces arrays instead of appending', () => {
|
|
const prev: UserSettings = {
|
|
views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } },
|
|
}
|
|
const result = mergeSettings(prev, {
|
|
views: { sprintBacklog: { collapsedPbis: ['c'] } },
|
|
})
|
|
expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c'])
|
|
})
|
|
|
|
it('does not mutate the previous object', () => {
|
|
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
|
|
const snapshot = JSON.parse(JSON.stringify(prev))
|
|
mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } })
|
|
expect(prev).toEqual(snapshot)
|
|
})
|
|
|
|
it('skips undefined values in the patch', () => {
|
|
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
|
|
const result = mergeSettings(prev, { views: undefined })
|
|
expect(result).toEqual(prev)
|
|
})
|
|
})
|
|
|
|
describe('parseUserSettings', () => {
|
|
it('returns defaults for null', () => {
|
|
expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS)
|
|
})
|
|
|
|
it('returns defaults for undefined', () => {
|
|
expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS)
|
|
})
|
|
|
|
it('returns defaults for invalid input', () => {
|
|
expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } }))
|
|
.toEqual(DEFAULT_USER_SETTINGS)
|
|
})
|
|
|
|
it('passes valid settings through', () => {
|
|
const valid = { views: { sprintBacklog: { sort: 'code' as const } } }
|
|
expect(parseUserSettings(valid)).toEqual(valid)
|
|
})
|
|
})
|
|
|
|
describe('UserSettingsSchema', () => {
|
|
it('rejects unknown top-level keys', () => {
|
|
const result = UserSettingsSchema.safeParse({ unknown: 1 })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts an empty object', () => {
|
|
expect(UserSettingsSchema.safeParse({}).success).toBe(true)
|
|
})
|
|
|
|
it('accepts the full shape', () => {
|
|
const result = UserSettingsSchema.safeParse({
|
|
views: {
|
|
sprintBacklog: {
|
|
filterPriority: 1,
|
|
filterStatus: 'OPEN',
|
|
sort: 'code',
|
|
sortDir: 'asc',
|
|
collapsedPbis: ['x'],
|
|
filterPopoverOpen: true,
|
|
},
|
|
pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' },
|
|
storyPanel: { sort: 'date' },
|
|
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
|
|
jobs: { timeFilter: '24h' },
|
|
ideasList: { filterStatuses: ['draft', 'planned'] },
|
|
},
|
|
devTools: { debugMode: true },
|
|
layout: {
|
|
splitPanePositions: { 'backlog-pid': [25, 35, 40] },
|
|
activeSprints: { 'product-1': 'sprint-1' },
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => {
|
|
const input = { views: { jobs: { timeFilter: '1h' as const } } }
|
|
const result = parseUserSettings(input)
|
|
expect(result).toEqual(input)
|
|
})
|
|
|
|
it('rejects an invalid views.jobs.timeFilter value', () => {
|
|
const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts layout-only settings', () => {
|
|
expect(UserSettingsSchema.safeParse({
|
|
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
|
}).success).toBe(true)
|
|
})
|
|
|
|
it('accepts null values in activeSprints (explicit "no active sprint")', () => {
|
|
const result = UserSettingsSchema.safeParse({
|
|
layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.layout?.activeSprints).toEqual({
|
|
'product-1': null,
|
|
'product-2': 'sprint-2',
|
|
})
|
|
}
|
|
})
|
|
|
|
it('accepts pendingSprintDraft with per-PBI intent and overrides', () => {
|
|
const result = UserSettingsSchema.safeParse({
|
|
workflow: {
|
|
pendingSprintDraft: {
|
|
'product-1': {
|
|
goal: 'Sprint goal',
|
|
pbiIntent: { pbiA: 'all', pbiB: 'none' },
|
|
storyOverrides: {
|
|
pbiA: { add: [], remove: ['story-1'] },
|
|
pbiB: { add: ['story-2'], remove: [] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('fills empty defaults for pbiIntent and storyOverrides in draft', () => {
|
|
const result = UserSettingsSchema.safeParse({
|
|
workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
const draft = result.data.workflow?.pendingSprintDraft?.['product-1']
|
|
expect(draft?.pbiIntent).toEqual({})
|
|
expect(draft?.storyOverrides).toEqual({})
|
|
}
|
|
})
|
|
|
|
it('rejects pendingSprintDraft with empty goal', () => {
|
|
const result = UserSettingsSchema.safeParse({
|
|
workflow: { pendingSprintDraft: { 'p': { goal: '' } } },
|
|
})
|
|
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: {
|
|
pendingSprintDraft: {
|
|
p: { goal: 'x', pbiIntent: { a: 'partial' } },
|
|
},
|
|
},
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|