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()
|
||||
})
|
||||
|
||||
it('setPendingSprintDraft persists draft optimistically + calls action', async () => {
|
||||
it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
setDraftAction.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const draft: PendingSprintDraft = {
|
||||
goal: 'Sprint 1',
|
||||
|
|
@ -113,33 +112,16 @@ describe('useUserSettingsStore', () => {
|
|||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toMatchObject({ goal: 'Sprint 1' })
|
||||
expect(setDraftAction).toHaveBeenCalledWith('product-1', draft)
|
||||
expect(setDraftAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'Old',
|
||||
goal: 'Legacy draft',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
},
|
||||
|
|
@ -148,7 +130,18 @@ describe('useUserSettingsStore', () => {
|
|||
},
|
||||
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
|
||||
.getState()
|
||||
|
|
@ -158,28 +151,19 @@ describe('useUserSettingsStore', () => {
|
|||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toBeUndefined()
|
||||
expect(clearDraftAction).toHaveBeenCalledWith('product-1')
|
||||
expect(clearDraftAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('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()
|
||||
|
|
@ -194,23 +178,14 @@ describe('useUserSettingsStore', () => {
|
|||
})
|
||||
|
||||
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'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: [], remove: ['story-1'] },
|
||||
},
|
||||
false,
|
||||
)
|
||||
setDraftAction.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
|
|
@ -226,23 +201,14 @@ describe('useUserSettingsStore', () => {
|
|||
})
|
||||
|
||||
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: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: ['story-1'], remove: [] },
|
||||
},
|
||||
false,
|
||||
)
|
||||
setDraftAction.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
|||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
||||
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 { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||
|
|
@ -152,8 +153,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprint definition banner (state A′) */}
|
||||
{/* Sprint definition banner (state A′) + beforeunload-guard */}
|
||||
<SprintDraftBanner productId={id} />
|
||||
<SprintDraftLeaveGuard productId={id} />
|
||||
|
||||
{/* Split pane */}
|
||||
<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,
|
||||
} from '@/actions/active-sprint'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import type { SprintStatusApi } from '@/lib/task-status'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
|
|
@ -49,6 +50,13 @@ export function SprintSwitcher({
|
|||
const [showClosed, setShowClosed] = useState(false)
|
||||
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 => {
|
||||
if (showClosed) return true
|
||||
if (s.id === activeSprint?.id) return true
|
||||
|
|
@ -161,6 +169,19 @@ export function SprintSwitcher({
|
|||
Toon afgeronde sprints
|
||||
</button>
|
||||
<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
|
||||
onClick={handleClearActiveSprint}
|
||||
disabled={!activeSprint || isPending}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,15 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
|||
|
||||
hydrate: (initial, isDemo) => {
|
||||
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.isDemo = isDemo
|
||||
})
|
||||
|
|
@ -92,7 +100,10 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
|||
},
|
||||
|
||||
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) => {
|
||||
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
|
||||
if (!s.entities.settings.workflow.pendingSprintDraft) {
|
||||
|
|
@ -100,34 +111,14 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
|||
}
|
||||
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
|
||||
// PBI-79 scope-aanpassing: session-only — lokale delete is voldoende.
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue