feat(PBI-74): migreer backlog-componenten naar workspace-store (Story 3)
Story 3 verplaatst alle UI-consumers van de oude vier stores
(useBacklogStore/usePlannerStore/useSelectionStore/useProductStore) naar de
nieuwe product-workspace-store. De oude stores blijven nog bestaan voor
hydration-wrapper en realtime-hook (dual-dispatch); Story 8 ruimt ze op.
- T-848 backlog-split-pane.tsx: leest activePbiId/activeStoryId uit
context-slice (primitives, geen useShallow nodig).
- T-849 pbi-list.tsx: selectVisiblePbis(useShallow); DnD via
applyOptimisticMutation('pbi-order' + optionele 'entity-patch' bij
cross-priority drag), met settle/rollback per server-result.
- T-850 story-panel.tsx: selectStoriesForActivePbi(useShallow); DnD via
applyOptimisticMutation('story-order' + entity-patch bij priority change).
- T-851 task-panel.tsx: selectTasksForActiveStory(useShallow); DnD via
applyOptimisticMutation('task-order'); detail-view (ensureTaskLoaded +
isDetail) zit in de task-dialog (apart component, niet in deze lijst).
- T-852 start-sprint-button.tsx: selectActivePbi + selectStoriesForActivePbi
voor free-story count.
- T-853 set-current-product.tsx: alleen workspace-store.setActiveProduct
(oude useProductStore-import verwijderd).
- T-854 G1/G2-audit: alle nieuwe selectors gebruiken module-level EMPTY
refs (G1) en useShallow voor lijsten (G2). Geen 'Maximum update depth'-
warnings tijdens npm test.
- T-855 tests bijgewerkt: backlog-split-pane.test, task-panel.test,
integration.test gebruiken nu setState op workspace-store (helpers
resetWorkspace/setActiveStoryAndTasks/selectPbi/selectStory).
Verify: lint+typecheck clean, 636/636 tests groen. UI-consumers van
oude stores zijn nu nul (uitgezonderd dual-dispatch in hydration-wrapper en
realtime-hook + dev-fingerprint-helper, die in Story 8/T-873/T-878 verdwijnen).
Refs: PBI-74, ST-1320, T-848..T-855
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a98e60fcc7
commit
5aec101c83
9 changed files with 257 additions and 159 deletions
|
|
@ -1,9 +1,16 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||
|
||||
function setSelection(pbiId: string | null, storyId: string | null) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = pbiId
|
||||
s.context.activeStoryId = storyId
|
||||
})
|
||||
}
|
||||
|
||||
const PANES = [
|
||||
<div key="a">PBI pane</div>,
|
||||
<div key="b">Stories pane</div>,
|
||||
|
|
@ -22,7 +29,7 @@ function renderPane() {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
|
||||
setSelection(null, null)
|
||||
// Force mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
|
@ -37,7 +44,7 @@ describe('BacklogSplitPane auto-switch', () => {
|
|||
|
||||
it('auto-switches to tab 1 when PBI is selected', () => {
|
||||
const { rerender } = renderPane()
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null })
|
||||
setSelection('pbi-1', null)
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
@ -52,7 +59,7 @@ describe('BacklogSplitPane auto-switch', () => {
|
|||
|
||||
it('auto-switches to tab 2 when story is selected', () => {
|
||||
const { rerender } = renderPane()
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
|
||||
setSelection('pbi-1', 'story-1')
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
@ -67,11 +74,11 @@ describe('BacklogSplitPane auto-switch', () => {
|
|||
|
||||
it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => {
|
||||
// Start with story selected (tab 2)
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
|
||||
setSelection('pbi-1', 'story-1')
|
||||
const { rerender } = renderPane()
|
||||
|
||||
// Cascade-reset: new PBI → story clears
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null })
|
||||
setSelection('pbi-2', null)
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore } from '@/stores/backlog-store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type {
|
||||
BacklogStory,
|
||||
BacklogTask,
|
||||
} from '@/stores/product-workspace/types'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
|
|
@ -61,19 +64,40 @@ const PBI_ID = 'pbi-1'
|
|||
const ALT_PBI_ID = 'pbi-2'
|
||||
const STORY_ID = 'story-1'
|
||||
|
||||
const STORIES = [
|
||||
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
||||
const STORIES: BacklogStory[] = [
|
||||
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
||||
]
|
||||
const TASKS = [
|
||||
const TASKS: BacklogTask[] = [
|
||||
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||
]
|
||||
|
||||
function resetStores() {
|
||||
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
|
||||
useBacklogStore.setState({
|
||||
pbis: [],
|
||||
storiesByPbi: { [PBI_ID]: STORIES },
|
||||
tasksByStory: { [STORY_ID]: TASKS },
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = null
|
||||
s.context.activePbiId = null
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
s.entities.pbisById = {}
|
||||
s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st]))
|
||||
s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t]))
|
||||
s.relations.pbiIds = []
|
||||
s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) }
|
||||
s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) }
|
||||
})
|
||||
}
|
||||
|
||||
function selectPbi(pbiId: string | null) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = pbiId
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
})
|
||||
}
|
||||
|
||||
function selectStory(pbiId: string | null, storyId: string | null) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = pbiId
|
||||
s.context.activeStoryId = storyId
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -89,42 +113,40 @@ describe('Backlog 3-pane integration', () => {
|
|||
})
|
||||
|
||||
it('StoryPanel shows stories when PBI is selected', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
|
||||
selectPbi(PBI_ID)
|
||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
expect(screen.getByText('Eerste story')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a story dispatches selectStory to the store', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
|
||||
it('clicking a story dispatches setActiveStory to the workspace-store', () => {
|
||||
selectPbi(PBI_ID)
|
||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
fireEvent.click(screen.getByText('Eerste story'))
|
||||
expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID)
|
||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID)
|
||||
})
|
||||
|
||||
it('cascade-reset: selecting different PBI clears selectedStoryId', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
useSelectionStore.getState().selectPbi(ALT_PBI_ID)
|
||||
expect(useSelectionStore.getState().selectedStoryId).toBeNull()
|
||||
it('cascade-reset: selecting different PBI clears activeStoryId', () => {
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull()
|
||||
})
|
||||
|
||||
it('TaskPanel shows tasks after story is selected', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('TaskPanel shows empty state after cascade-reset', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
// Reset via selectPbi
|
||||
useSelectionStore.getState().selectPbi(ALT_PBI_ID)
|
||||
// Re-render reflects new store state
|
||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('selected story card has isSelected highlight class applied', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
// bg-primary-container is applied when isSelected
|
||||
const selected = container.querySelector('.bg-primary-container')
|
||||
|
|
|
|||
|
|
@ -1,8 +1,33 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore } from '@/stores/backlog-store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type { BacklogTask } from '@/stores/product-workspace/types'
|
||||
|
||||
function resetWorkspace() {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = null
|
||||
s.context.activePbiId = null
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
s.entities.pbisById = {}
|
||||
s.entities.storiesById = {}
|
||||
s.entities.tasksById = {}
|
||||
s.relations.pbiIds = []
|
||||
s.relations.storyIdsByPbi = {}
|
||||
s.relations.taskIdsByStory = {}
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeStoryId = storyId
|
||||
if (storyId) {
|
||||
s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id)
|
||||
for (const task of tasks) s.entities.tasksById[task.id] = task
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
|
|
@ -57,8 +82,7 @@ function renderPanel(isDemo = false) {
|
|||
describe('TaskPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear()
|
||||
useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null })
|
||||
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
|
||||
resetWorkspace()
|
||||
})
|
||||
|
||||
it('shows empty state when no story is selected', () => {
|
||||
|
|
@ -67,40 +91,35 @@ describe('TaskPanel', () => {
|
|||
})
|
||||
|
||||
it('shows empty state with action when story selected but no tasks', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(STORY_ID, [])
|
||||
renderPanel()
|
||||
expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy()
|
||||
expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders task cards when tasks are present', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
||||
expect(screen.getByText('Tweede taak')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders status badges on task cards', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
expect(screen.getByText('To Do')).toBeTruthy()
|
||||
expect(screen.getByText('Bezig')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('task cards are rendered inside a grid container', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
const { container } = renderPanel()
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking + button calls router.push with newTask params', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(STORY_ID, [])
|
||||
renderPanel()
|
||||
const buttons = screen.getAllByText('+ Nieuwe taak')
|
||||
fireEvent.click(buttons[0])
|
||||
|
|
@ -108,16 +127,14 @@ describe('TaskPanel', () => {
|
|||
})
|
||||
|
||||
it('clicking task card calls router.push with editTask param', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
fireEvent.click(screen.getByText('Eerste taak'))
|
||||
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`)
|
||||
})
|
||||
|
||||
it('+ button is disabled in demo mode', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(STORY_ID, [])
|
||||
renderPanel(true)
|
||||
const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button')
|
||||
expect(btn).toBeTruthy()
|
||||
|
|
@ -125,8 +142,7 @@ describe('TaskPanel', () => {
|
|||
})
|
||||
|
||||
it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
// In demo mode, listeners ({} from useSortable mock) are not spread onto the card.
|
||||
// The mock always returns empty listeners, so we just verify the cards render without error.
|
||||
renderPanel(true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue