feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met compacte intent-shape (pbiIntent + per-PBI storyOverrides). - actions/sprint-draft.ts: setPendingSprintDraftAction + clearPendingSprintDraftAction met product-membership-check + Zod-validatie. - stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride. Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests zonder DATABASE_URL niet falen. - Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2af6f24598
commit
56c55e1813
6 changed files with 636 additions and 0 deletions
167
__tests__/actions/sprint-draft.test.ts
Normal file
167
__tests__/actions/sprint-draft.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn().mockResolvedValue({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||
}))
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
clearPendingSprintDraftAction,
|
||||
setPendingSprintDraftAction,
|
||||
} from '@/actions/sprint-draft'
|
||||
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
user: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
const validDraft: PendingSprintDraft = {
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
|
||||
}
|
||||
|
||||
describe('setPendingSprintDraftAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.user.findUnique.mockReset()
|
||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('persists draft for accessible product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves drafts for other products', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: {
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
|
||||
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
|
||||
})
|
||||
|
||||
it('rejects invalid draft (empty goal)', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', {
|
||||
...validDraft,
|
||||
goal: '',
|
||||
} as PendingSprintDraft)
|
||||
|
||||
expect(result).toHaveProperty('error')
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when product not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearPendingSprintDraftAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.user.findUnique.mockReset()
|
||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('removes draft key for product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: {
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
|
||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await clearPendingSprintDraftAction('p1')
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
|
||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when there is no draft for the product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||
|
||||
const result = await clearPendingSprintDraftAction('p1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when product not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await clearPendingSprintDraftAction('p1')
|
||||
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
})
|
||||
})
|
||||
|
|
@ -135,4 +135,52 @@ describe('UserSettingsSchema', () => {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 unknown intent value', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p: { goal: 'x', pbiIntent: { a: 'partial' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const updateAction = vi.fn()
|
||||
const setDraftAction = vi.fn()
|
||||
const clearDraftAction = vi.fn()
|
||||
|
||||
vi.mock('@/actions/user-settings', () => ({
|
||||
updateUserSettingsAction: (...args: unknown[]) => updateAction(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/actions/sprint-draft', () => ({
|
||||
setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args),
|
||||
clearPendingSprintDraftAction: (...args: unknown[]) =>
|
||||
clearDraftAction(...args),
|
||||
}))
|
||||
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||
|
||||
function resetStore() {
|
||||
useUserSettingsStore.setState((s) => {
|
||||
|
|
@ -20,6 +29,8 @@ function resetStore() {
|
|||
beforeEach(() => {
|
||||
resetStore()
|
||||
updateAction.mockReset()
|
||||
setDraftAction.mockReset()
|
||||
clearDraftAction.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -85,6 +96,164 @@ describe('useUserSettingsStore', () => {
|
|||
expect(updateAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setPendingSprintDraft persists draft optimistically + calls action', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
setDraftAction.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const draft: PendingSprintDraft = {
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
storyOverrides: {},
|
||||
}
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.setPendingSprintDraft('product-1', draft)
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toMatchObject({ goal: 'Sprint 1' })
|
||||
expect(setDraftAction).toHaveBeenCalledWith('product-1', draft)
|
||||
})
|
||||
|
||||
it('setPendingSprintDraft rolls back on server error', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
setDraftAction.mockResolvedValueOnce({ error: 'boom' })
|
||||
|
||||
const draft: PendingSprintDraft = {
|
||||
goal: 'Sprint X',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
}
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.setPendingSprintDraft('product-1', draft)
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clearPendingSprintDraft removes key optimistically', async () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'Old',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
clearDraftAction.mockResolvedValueOnce({ success: true })
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.clearPendingSprintDraft('product-1')
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toBeUndefined()
|
||||
expect(clearDraftAction).toHaveBeenCalledWith('product-1')
|
||||
})
|
||||
|
||||
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'g',
|
||||
pbiIntent: { pbiA: 'none' },
|
||||
storyOverrides: {
|
||||
pbiA: { add: ['s-1'], remove: [] },
|
||||
pbiB: { add: [], remove: ['s-2'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
setDraftAction.mockResolvedValue({ success: true })
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertPbiIntent('product-1', 'pbiA', 'all')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.pbiIntent.pbiA).toBe('all')
|
||||
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||
expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] })
|
||||
})
|
||||
|
||||
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: [], remove: ['story-1'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
setDraftAction.mockResolvedValue({ success: true })
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.storyOverrides.pbiA).toEqual({
|
||||
add: ['story-1'],
|
||||
remove: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: ['story-1'], remove: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
setDraftAction.mockResolvedValue({ success: true })
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applyServerPatch merges without optimistic state', () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{ views: { sprintBacklog: { sort: 'code' } } },
|
||||
|
|
|
|||
121
actions/sprint-draft.ts
Normal file
121
actions/sprint-draft.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import {
|
||||
mergeSettings,
|
||||
parseUserSettings,
|
||||
type PendingSprintDraft,
|
||||
type UserSettings,
|
||||
} from '@/lib/user-settings'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
const StoryOverridesSchema = z.object({
|
||||
add: z.array(z.string()),
|
||||
remove: z.array(z.string()),
|
||||
}).strict()
|
||||
|
||||
const DraftSchema = z.object({
|
||||
goal: z.string().min(1),
|
||||
startAt: z.string().datetime().optional(),
|
||||
endAt: z.string().datetime().optional(),
|
||||
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
|
||||
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
|
||||
}).strict()
|
||||
|
||||
const SetSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
draft: DraftSchema,
|
||||
})
|
||||
|
||||
const ClearSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
})
|
||||
|
||||
async function ensureProductAccess(userId: string, productId: string) {
|
||||
return prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
async function readUserSettings(userId: string): Promise<UserSettings> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { settings: true },
|
||||
})
|
||||
return parseUserSettings(user?.settings)
|
||||
}
|
||||
|
||||
async function writeUserSettings(userId: string, next: UserSettings) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { settings: next as unknown as Prisma.InputJsonValue },
|
||||
})
|
||||
}
|
||||
|
||||
export async function setPendingSprintDraftAction(
|
||||
productId: string,
|
||||
draft: PendingSprintDraft,
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = SetSchema.safeParse({ productId, draft })
|
||||
if (!parsed.success) {
|
||||
return { error: 'Ongeldige draft', issues: parsed.error.issues }
|
||||
}
|
||||
|
||||
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
||||
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||
|
||||
const current = await readUserSettings(session.userId)
|
||||
const patch: Partial<UserSettings> = {
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
...(current.workflow?.pendingSprintDraft ?? {}),
|
||||
[parsed.data.productId]: parsed.data.draft,
|
||||
},
|
||||
},
|
||||
}
|
||||
await writeUserSettings(session.userId, mergeSettings(current, patch))
|
||||
revalidatePath('/', 'layout')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function clearPendingSprintDraftAction(productId: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = ClearSchema.safeParse({ productId })
|
||||
if (!parsed.success) return { error: 'Ongeldig product-id' }
|
||||
|
||||
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
||||
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||
|
||||
const current = await readUserSettings(session.userId)
|
||||
const existingMap = current.workflow?.pendingSprintDraft
|
||||
if (!existingMap || !(parsed.data.productId in existingMap)) {
|
||||
return { success: true }
|
||||
}
|
||||
const nextMap = { ...existingMap }
|
||||
delete nextMap[parsed.data.productId]
|
||||
const next: UserSettings = {
|
||||
...current,
|
||||
workflow: { ...current.workflow, pendingSprintDraft: nextMap },
|
||||
}
|
||||
await writeUserSettings(session.userId, next)
|
||||
revalidatePath('/', 'layout')
|
||||
return { success: true }
|
||||
}
|
||||
|
|
@ -48,13 +48,36 @@ const LayoutPrefs = z.object({
|
|||
activeSprints: z.record(z.string(), z.string().nullable()).optional(),
|
||||
}).strict()
|
||||
|
||||
const PbiIntent = z.enum(['all', 'none'])
|
||||
|
||||
const StoryOverrides = z.object({
|
||||
add: z.array(z.string()),
|
||||
remove: z.array(z.string()),
|
||||
}).strict()
|
||||
|
||||
const PendingSprintDraftSchema = z.object({
|
||||
goal: z.string().min(1),
|
||||
startAt: z.string().datetime().optional(),
|
||||
endAt: z.string().datetime().optional(),
|
||||
pbiIntent: z.record(z.string(), PbiIntent).default({}),
|
||||
storyOverrides: z.record(z.string(), StoryOverrides).default({}),
|
||||
}).strict()
|
||||
|
||||
const WorkflowPrefs = z.object({
|
||||
pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(),
|
||||
}).strict()
|
||||
|
||||
export const UserSettingsSchema = z.object({
|
||||
views: ViewsPrefs.optional(),
|
||||
devTools: DevToolsPrefs.optional(),
|
||||
layout: LayoutPrefs.optional(),
|
||||
workflow: WorkflowPrefs.optional(),
|
||||
}).strict()
|
||||
|
||||
export type UserSettings = z.infer<typeof UserSettingsSchema>
|
||||
export type PendingSprintDraft = z.infer<typeof PendingSprintDraftSchema>
|
||||
export type PbiIntent = z.infer<typeof PbiIntent>
|
||||
export type StoryOverrides = z.infer<typeof StoryOverrides>
|
||||
|
||||
export const DEFAULT_USER_SETTINGS: UserSettings = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { immer } from 'zustand/middleware/immer'
|
|||
import {
|
||||
DEFAULT_USER_SETTINGS,
|
||||
mergeSettings,
|
||||
type PbiIntent,
|
||||
type PendingSprintDraft,
|
||||
type UserSettings,
|
||||
} from '@/lib/user-settings'
|
||||
import { updateUserSettingsAction } from '@/actions/user-settings'
|
||||
|
|
@ -28,6 +30,22 @@ interface UserSettingsActions {
|
|||
hydrate: (initial: UserSettings, isDemo: boolean) => void
|
||||
setPref: (path: SettingsPath, value: unknown) => Promise<void>
|
||||
applyServerPatch: (patch: Partial<UserSettings>) => void
|
||||
setPendingSprintDraft: (
|
||||
productId: string,
|
||||
draft: PendingSprintDraft,
|
||||
) => Promise<void>
|
||||
clearPendingSprintDraft: (productId: string) => Promise<void>
|
||||
upsertPbiIntent: (
|
||||
productId: string,
|
||||
pbiId: string,
|
||||
intent: PbiIntent,
|
||||
) => Promise<void>
|
||||
upsertStoryOverride: (
|
||||
productId: string,
|
||||
pbiId: string,
|
||||
storyId: string,
|
||||
kind: 'add' | 'remove' | 'clear',
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
let nextMutationId = 1
|
||||
|
|
@ -73,6 +91,96 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
|||
})
|
||||
},
|
||||
|
||||
setPendingSprintDraft: async (productId, draft) => {
|
||||
const prev = get().entities.settings as UserSettings
|
||||
set((s) => {
|
||||
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
|
||||
if (!s.entities.settings.workflow.pendingSprintDraft) {
|
||||
s.entities.settings.workflow.pendingSprintDraft = {}
|
||||
}
|
||||
s.entities.settings.workflow.pendingSprintDraft[productId] = draft
|
||||
})
|
||||
if (get().context.isDemo) return
|
||||
const { setPendingSprintDraftAction } = await import(
|
||||
'@/actions/sprint-draft'
|
||||
)
|
||||
const result = await setPendingSprintDraftAction(productId, draft)
|
||||
if ('error' in result) {
|
||||
set((s) => {
|
||||
s.entities.settings = prev as UserSettings
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
clearPendingSprintDraft: async (productId) => {
|
||||
const prev = get().entities.settings as UserSettings
|
||||
set((s) => {
|
||||
const map = s.entities.settings.workflow?.pendingSprintDraft
|
||||
if (map) delete map[productId]
|
||||
})
|
||||
if (get().context.isDemo) return
|
||||
const { clearPendingSprintDraftAction } = await import(
|
||||
'@/actions/sprint-draft'
|
||||
)
|
||||
const result = await clearPendingSprintDraftAction(productId)
|
||||
if ('error' in result) {
|
||||
set((s) => {
|
||||
s.entities.settings = prev as UserSettings
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
upsertPbiIntent: async (productId, pbiId, intent) => {
|
||||
const current =
|
||||
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||
if (!current) return
|
||||
const nextOverrides = { ...current.storyOverrides }
|
||||
delete nextOverrides[pbiId]
|
||||
const next: PendingSprintDraft = {
|
||||
...current,
|
||||
pbiIntent: { ...current.pbiIntent, [pbiId]: intent },
|
||||
storyOverrides: nextOverrides,
|
||||
}
|
||||
await get().setPendingSprintDraft(productId, next)
|
||||
},
|
||||
|
||||
upsertStoryOverride: async (productId, pbiId, storyId, kind) => {
|
||||
const current =
|
||||
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||
if (!current) return
|
||||
const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] }
|
||||
const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId)
|
||||
let nextEntry: { add: string[]; remove: string[] }
|
||||
switch (kind) {
|
||||
case 'add':
|
||||
nextEntry = {
|
||||
add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId],
|
||||
remove: dropFrom(existing.remove),
|
||||
}
|
||||
break
|
||||
case 'remove':
|
||||
nextEntry = {
|
||||
add: dropFrom(existing.add),
|
||||
remove: existing.remove.includes(storyId)
|
||||
? existing.remove
|
||||
: [...existing.remove, storyId],
|
||||
}
|
||||
break
|
||||
case 'clear':
|
||||
default:
|
||||
nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) }
|
||||
break
|
||||
}
|
||||
const nextOverrides = { ...current.storyOverrides }
|
||||
if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) {
|
||||
delete nextOverrides[pbiId]
|
||||
} else {
|
||||
nextOverrides[pbiId] = nextEntry
|
||||
}
|
||||
const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides }
|
||||
await get().setPendingSprintDraft(productId, next)
|
||||
},
|
||||
|
||||
setPref: async (path, value) => {
|
||||
const patch = patchFromPath(path, value)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue