feat(PBI-75): sprint task-edit client-side via workspace-store (#183)

Klik op een taak in het sprint-scherm opent de edit-dialog nu
client-side via setActiveTask op de sprint-workspace-store.
Geen URL-navigatie, geen volledige server re-render — alleen
GET /api/tasks/{id} voor het detail. SSE propageert server-saves
automatisch terug.

- TaskDialog: optionele onClose/onSaved callbacks (closePath
  optional gemaakt — backwards compatible)
- SprintTaskDialogMount: nieuwe client-component die
  selectActiveTask consumeert en TaskDialog rendert
- SprintUrlTaskSync: deeplink (?editTask=<id>) → store
- Sprint page: mounts toegevoegd, editTask searchParam +
  EditTaskLoader-Suspense verwijderd
- TaskList.openEditDialog roept setActiveTask aan ipv router.push
- Vitest integratie-test voor SprintTaskDialogMount

Out-of-scope (follow-up PBIs): newTask-flow, mobile, product-backlog.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 08:21:42 +02:00 committed by GitHub
parent 3b5cee823c
commit a9b53dedf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 335 additions and 25 deletions

View file

@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/actions/tasks', () => ({
saveTask: vi.fn(),
deleteTask: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types'
const TASK_DETAIL: SprintWorkspaceTaskDetail = {
id: 't1',
code: 'T-1',
title: 'Mijn taak',
description: 'Beschrijving',
priority: 2,
sort_order: 1,
status: 'in_progress',
story_id: 'story-1',
sprint_id: 'sprint-1',
created_at: new Date('2026-01-15'),
_detail: true,
implementation_plan: 'Stap 1\nStap 2',
}
function resetStore() {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = null
s.context.activeSprintId = null
s.context.activeStoryId = null
s.context.activeTaskId = null
s.entities.sprintsById = {}
s.entities.storiesById = {}
s.entities.tasksById = {}
s.relations.sprintIdsByProduct = {}
s.relations.storyIdsBySprint = {}
s.relations.taskIdsByStory = {}
s.loading.loadedProductSprintsIds = {}
s.loading.loadingProductId = null
s.loading.loadedSprintIds = {}
s.loading.loadingSprintId = null
s.loading.loadedStoryIds = {}
s.loading.loadedTaskIds = {}
s.loading.activeRequestId = null
s.pendingMutations = {}
})
}
beforeEach(() => {
resetStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SprintTaskDialogMount', () => {
it('rendert niets wanneer er geen active task is', () => {
const { container } = render(
<SprintTaskDialogMount productId="p1" isDemo={false} />,
)
expect(container.textContent).toBe('')
})
it('rendert niets wanneer active task geen _detail heeft', () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = {
id: 't1',
code: 'T-1',
title: 'Mijn taak',
description: null,
priority: 2,
sort_order: 1,
status: 'todo',
story_id: 'story-1',
sprint_id: 'sprint-1',
created_at: new Date(),
}
s.context.activeTaskId = 't1'
})
const { container } = render(
<SprintTaskDialogMount productId="p1" isDemo={false} />,
)
expect(container.textContent).toBe('')
})
it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = TASK_DETAIL
s.context.activeTaskId = 't1'
})
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
expect(screen.getByText('Taak bewerken')).toBeTruthy()
expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak')
})
it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = TASK_DETAIL
s.context.activeTaskId = 't1'
})
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
fireEvent.click(screen.getByRole('button', { name: 'Annuleren' }))
await waitFor(() => {
expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull()
})
})
})