feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.
Wijzigingen:
- stores/user-settings/store.ts:
- hydrate() strip nu workflow.pendingSprintDraft uit serverstate
(legacy DB-entries blijven harmless aanwezig maar worden niet
gehydreerd → effectief unreachable voor de UI).
- setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
geen import van sprint-draft-actions, geen server-roundtrip.
- upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
een beforeunload-listener zolang er een draft is. Browser-refresh,
tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
setPendingSprintDraft i.p.v. legacy hydrate).
setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e4252cad3e
commit
2a4ee6aded
5 changed files with 114 additions and 97 deletions
|
|
@ -96,9 +96,8 @@ describe('useUserSettingsStore', () => {
|
||||||
expect(updateAction).not.toHaveBeenCalled()
|
expect(updateAction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setPendingSprintDraft persists draft optimistically + calls action', async () => {
|
it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
|
||||||
useUserSettingsStore.getState().hydrate({}, false)
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
setDraftAction.mockResolvedValueOnce({ success: true })
|
|
||||||
|
|
||||||
const draft: PendingSprintDraft = {
|
const draft: PendingSprintDraft = {
|
||||||
goal: 'Sprint 1',
|
goal: 'Sprint 1',
|
||||||
|
|
@ -113,33 +112,16 @@ describe('useUserSettingsStore', () => {
|
||||||
expect(
|
expect(
|
||||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||||
).toMatchObject({ goal: 'Sprint 1' })
|
).toMatchObject({ goal: 'Sprint 1' })
|
||||||
expect(setDraftAction).toHaveBeenCalledWith('product-1', draft)
|
expect(setDraftAction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setPendingSprintDraft rolls back on server error', async () => {
|
it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
|
||||||
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(
|
useUserSettingsStore.getState().hydrate(
|
||||||
{
|
{
|
||||||
workflow: {
|
workflow: {
|
||||||
pendingSprintDraft: {
|
pendingSprintDraft: {
|
||||||
'product-1': {
|
'product-1': {
|
||||||
goal: 'Old',
|
goal: 'Legacy draft',
|
||||||
pbiIntent: {},
|
pbiIntent: {},
|
||||||
storyOverrides: {},
|
storyOverrides: {},
|
||||||
},
|
},
|
||||||
|
|
@ -148,7 +130,18 @@ describe('useUserSettingsStore', () => {
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
clearDraftAction.mockResolvedValueOnce({ success: true })
|
|
||||||
|
const s = useUserSettingsStore.getState()
|
||||||
|
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
|
goal: 'Old',
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {},
|
||||||
|
})
|
||||||
|
|
||||||
await useUserSettingsStore
|
await useUserSettingsStore
|
||||||
.getState()
|
.getState()
|
||||||
|
|
@ -158,28 +151,19 @@ describe('useUserSettingsStore', () => {
|
||||||
expect(
|
expect(
|
||||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
expect(clearDraftAction).toHaveBeenCalledWith('product-1')
|
expect(clearDraftAction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
|
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
|
||||||
useUserSettingsStore.getState().hydrate(
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
{
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
workflow: {
|
|
||||||
pendingSprintDraft: {
|
|
||||||
'product-1': {
|
|
||||||
goal: 'g',
|
goal: 'g',
|
||||||
pbiIntent: { pbiA: 'none' },
|
pbiIntent: { pbiA: 'none' },
|
||||||
storyOverrides: {
|
storyOverrides: {
|
||||||
pbiA: { add: ['s-1'], remove: [] },
|
pbiA: { add: ['s-1'], remove: [] },
|
||||||
pbiB: { add: [], remove: ['s-2'] },
|
pbiB: { add: [], remove: ['s-2'] },
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
setDraftAction.mockResolvedValue({ success: true })
|
|
||||||
|
|
||||||
await useUserSettingsStore
|
await useUserSettingsStore
|
||||||
.getState()
|
.getState()
|
||||||
|
|
@ -194,23 +178,14 @@ describe('useUserSettingsStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
|
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
|
||||||
useUserSettingsStore.getState().hydrate(
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
{
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
workflow: {
|
|
||||||
pendingSprintDraft: {
|
|
||||||
'product-1': {
|
|
||||||
goal: 'g',
|
goal: 'g',
|
||||||
pbiIntent: {},
|
pbiIntent: {},
|
||||||
storyOverrides: {
|
storyOverrides: {
|
||||||
pbiA: { add: [], remove: ['story-1'] },
|
pbiA: { add: [], remove: ['story-1'] },
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
setDraftAction.mockResolvedValue({ success: true })
|
|
||||||
|
|
||||||
await useUserSettingsStore
|
await useUserSettingsStore
|
||||||
.getState()
|
.getState()
|
||||||
|
|
@ -226,23 +201,14 @@ describe('useUserSettingsStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
|
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
|
||||||
useUserSettingsStore.getState().hydrate(
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
{
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
workflow: {
|
|
||||||
pendingSprintDraft: {
|
|
||||||
'product-1': {
|
|
||||||
goal: 'g',
|
goal: 'g',
|
||||||
pbiIntent: {},
|
pbiIntent: {},
|
||||||
storyOverrides: {
|
storyOverrides: {
|
||||||
pbiA: { add: ['story-1'], remove: [] },
|
pbiA: { add: ['story-1'], remove: [] },
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
setDraftAction.mockResolvedValue({ success: true })
|
|
||||||
|
|
||||||
await useUserSettingsStore
|
await useUserSettingsStore
|
||||||
.getState()
|
.getState()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||||
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
||||||
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
|
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
|
||||||
|
import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard'
|
||||||
import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
|
import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
|
||||||
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
||||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
|
|
@ -152,8 +153,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sprint definition banner (state A′) */}
|
{/* Sprint definition banner (state A′) + beforeunload-guard */}
|
||||||
<SprintDraftBanner productId={id} />
|
<SprintDraftBanner productId={id} />
|
||||||
|
<SprintDraftLeaveGuard productId={id} />
|
||||||
|
|
||||||
{/* Split pane */}
|
{/* Split pane */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
|
|
||||||
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
|
||||||
|
interface SprintDraftLeaveGuardProps {
|
||||||
|
productId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft
|
||||||
|
* loopt voor dit product. De draft is session-only en gaat verloren bij
|
||||||
|
* refresh/close — deze guard zorgt dat de gebruiker dat eerst bevestigt.
|
||||||
|
* Voor in-app route-changes (klikken op een andere product) doet Next.js
|
||||||
|
* geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow.
|
||||||
|
*/
|
||||||
|
export function SprintDraftLeaveGuard({
|
||||||
|
productId,
|
||||||
|
}: SprintDraftLeaveGuardProps) {
|
||||||
|
const hasDraft = useUserSettingsStore(
|
||||||
|
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDraft) return
|
||||||
|
function handler(e: BeforeUnloadEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Moderne browsers tonen een eigen vertaalde tekst; returnValue is
|
||||||
|
// alleen nodig voor legacy compat.
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [hasDraft])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
switchActiveSprintAction,
|
switchActiveSprintAction,
|
||||||
} from '@/actions/active-sprint'
|
} from '@/actions/active-sprint'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import type { SprintStatusApi } from '@/lib/task-status'
|
import type { SprintStatusApi } from '@/lib/task-status'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
|
@ -49,6 +50,13 @@ export function SprintSwitcher({
|
||||||
const [showClosed, setShowClosed] = useState(false)
|
const [showClosed, setShowClosed] = useState(false)
|
||||||
const buildingSet = new Set(buildingSprintIds)
|
const buildingSet = new Set(buildingSprintIds)
|
||||||
|
|
||||||
|
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
|
||||||
|
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
|
||||||
|
// page-refresh/leave is hij weg.
|
||||||
|
const draftGoal = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
const visibleSprints = sprints.filter(s => {
|
const visibleSprints = sprints.filter(s => {
|
||||||
if (showClosed) return true
|
if (showClosed) return true
|
||||||
if (s.id === activeSprint?.id) return true
|
if (s.id === activeSprint?.id) return true
|
||||||
|
|
@ -161,6 +169,19 @@ export function SprintSwitcher({
|
||||||
Toon afgeronde sprints
|
Toon afgeronde sprints
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{draftGoal && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled
|
||||||
|
className="italic text-tertiary opacity-90 cursor-default"
|
||||||
|
data-debug-id="sprint-switcher__concept"
|
||||||
|
>
|
||||||
|
<span className="shrink-0">⚙ Concept —</span>
|
||||||
|
<span className="truncate">{draftGoal}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={handleClearActiveSprint}
|
onClick={handleClearActiveSprint}
|
||||||
disabled={!activeSprint || isPending}
|
disabled={!activeSprint || isPending}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,15 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
||||||
|
|
||||||
hydrate: (initial, isDemo) => {
|
hydrate: (initial, isDemo) => {
|
||||||
set((draft) => {
|
set((draft) => {
|
||||||
draft.entities.settings = initial as UserSettings
|
// PBI-79 scope-aanpassing: pendingSprintDraft is session-only;
|
||||||
|
// eventuele legacy DB-entries van vóór deze aanpassing worden bij
|
||||||
|
// hydratatie weggegooid zodat de draft niet 'spookt'.
|
||||||
|
const stripped: UserSettings = { ...initial }
|
||||||
|
if (stripped.workflow?.pendingSprintDraft) {
|
||||||
|
stripped.workflow = { ...stripped.workflow }
|
||||||
|
delete stripped.workflow.pendingSprintDraft
|
||||||
|
}
|
||||||
|
draft.entities.settings = stripped
|
||||||
draft.context.hydrated = true
|
draft.context.hydrated = true
|
||||||
draft.context.isDemo = isDemo
|
draft.context.isDemo = isDemo
|
||||||
})
|
})
|
||||||
|
|
@ -92,7 +100,10 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
||||||
},
|
},
|
||||||
|
|
||||||
setPendingSprintDraft: async (productId, draft) => {
|
setPendingSprintDraft: async (productId, draft) => {
|
||||||
const prev = get().entities.settings as UserSettings
|
// PBI-79 scope-aanpassing: session-only. Geen server-roundtrip;
|
||||||
|
// de draft leeft uitsluitend in deze store-instantie en is bij
|
||||||
|
// page-refresh/leave weg (zie SprintDraftLeaveGuard voor de
|
||||||
|
// beforeunload-warning).
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
|
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
|
||||||
if (!s.entities.settings.workflow.pendingSprintDraft) {
|
if (!s.entities.settings.workflow.pendingSprintDraft) {
|
||||||
|
|
@ -100,34 +111,14 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
||||||
}
|
}
|
||||||
s.entities.settings.workflow.pendingSprintDraft[productId] = draft
|
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) => {
|
clearPendingSprintDraft: async (productId) => {
|
||||||
const prev = get().entities.settings as UserSettings
|
// PBI-79 scope-aanpassing: session-only — lokale delete is voldoende.
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const map = s.entities.settings.workflow?.pendingSprintDraft
|
const map = s.entities.settings.workflow?.pendingSprintDraft
|
||||||
if (map) delete map[productId]
|
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) => {
|
upsertPbiIntent: async (productId, pbiId, intent) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue