feat(M14): 3-pane backlog — generic SplitPane, BacklogStore, SSE realtime, card-grid TaskPanel (#22)
* feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary number of panes with n-1 draggable dividers and JSON cookie persistence. Replaces TriplePane; mobile renders tabs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(split-pane): migrate callers to new panes[] API Backlog page and sprint board now use generic SplitPane. TriplePane removed; sprint board uses 3-pane with defaultSplit=[28,35,37]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(split-pane): add unit tests for 2/3-pane, cookie-restore, mobile tabs Added jsdom + @testing-library/react devDeps for component testing. 7 cases: render, divider count, cookie restore, invalid cookie fallback, mobile tab render/switch, and no-dividers-on-mobile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add BacklogStore Zustand store with applyChange reducer State: pbis, storiesByPbi, tasksByStory. setInitialData for server hydration; applyChange(entity, op, data) handles I/U/D for SSE events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): server-fetch tasks + hydrate BacklogStore on page load Page now fetches tasks parallel to stories and groups by story_id. BacklogHydrationWrapper calls setInitialData on mount so the store is ready for downstream SSE consumers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add EmptyPanel shared component, replace inline empty states EmptyPanel takes title?, message, and optional action with DemoTooltip. Replaces duplicate inline empty-state markup in pbi-list and story-panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add TaskPanel with sortable rows and TaskDialog wiring Reads selectedStoryId + tasksByStory from stores. DnD reorder via reorderTasksAction. Row click → ?editTask, + button → ?newTask&storyId. DemoTooltip on drag handles and + button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): wire TaskPanel + TaskDialog into backlog page 3-pane SplitPane [20,45,35]. searchParams for newTask/editTask. TaskDialog and EditTaskLoader render on ?newTask and ?editTask. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add TaskPanel tests for render states and click handlers 7 cases: no-story empty, no-tasks empty+action, tasks render, + button router.push, row click router.push, demo disabled button, demo disabled handles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate PbiList to store-driven via useBacklogStore Removes pbis prop; reads from useBacklogStore(s => s.pbis) so SSE updates reflect in real-time without prop drilling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate StoryPanel to store-driven + selectStory on click Removes storiesByPbi prop; reads from useBacklogStore. Card click now dispatches selectStory(id) + shows isSelected highlight. Edit moved to inline pencil button. page.tsx drops pbis/storiesByPbi props. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add 3-pane integration tests for click-cascade flow Covers: empty states, PBI→stories, story→tasks, cascade-reset, isSelected highlight. localStorage mocked for sort-mode persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1115): SSE backlog realtime — endpoint, hook, hydration mount, tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ST-1116): update functional-spec (3-pane backlog + mobile) and architecture (backlog SSE + backlog-store) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1117): TaskPanel card-grid — BacklogCard + rectSortingStrategy, tests updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): correct PbiStatusApi type and remove duplicate mock keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6cd98129f2
commit
8877ea469d
22 changed files with 2474 additions and 305 deletions
131
__tests__/api/backlog-realtime.test.ts
Normal file
131
__tests__/api/backlog-realtime.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
getAccessibleProduct: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { GET } from '@/app/api/realtime/backlog/route'
|
||||||
|
import { useBacklogStore } from '@/stores/backlog-store'
|
||||||
|
|
||||||
|
const mockGetAccessibleProduct = getAccessibleProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function makeReq(productId?: string): NextRequest {
|
||||||
|
const url = productId
|
||||||
|
? `http://localhost/api/realtime/backlog?product_id=${productId}`
|
||||||
|
: 'http://localhost/api/realtime/backlog'
|
||||||
|
return {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
nextUrl: new URL(url),
|
||||||
|
} as unknown as NextRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/realtime/backlog', () => {
|
||||||
|
it('401 when not authenticated', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||||
|
const res = await GET(makeReq('prod-1'))
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(mockGetAccessibleProduct).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('400 when product_id is missing', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
const res = await GET(makeReq())
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('403 when user has no access to the product', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
mockGetAccessibleProduct.mockResolvedValue(null)
|
||||||
|
const res = await GET(makeReq('prod-1'))
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('500 when DIRECT_URL and DATABASE_URL are absent', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
|
||||||
|
|
||||||
|
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
|
||||||
|
delete process.env.DIRECT_URL
|
||||||
|
delete process.env.DATABASE_URL
|
||||||
|
try {
|
||||||
|
const res = await GET(makeReq('prod-1'))
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
} finally {
|
||||||
|
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
|
||||||
|
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo user is allowed (no 403) when product is accessible', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
||||||
|
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
|
||||||
|
|
||||||
|
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
|
||||||
|
delete process.env.DIRECT_URL
|
||||||
|
delete process.env.DATABASE_URL
|
||||||
|
try {
|
||||||
|
const res = await GET(makeReq('prod-1'))
|
||||||
|
// Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
} finally {
|
||||||
|
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
|
||||||
|
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// shouldEmit scope filter — white-box unit tests
|
||||||
|
describe('shouldEmit scope filter (via backlog-store reducer)', () => {
|
||||||
|
it('applyChange: pbi INSERT adds to pbis array', () => {
|
||||||
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
|
||||||
|
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
|
||||||
|
useBacklogStore.getState().applyChange('pbi', 'I', pbi)
|
||||||
|
expect(useBacklogStore.getState().pbis).toHaveLength(1)
|
||||||
|
expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyChange: pbi UPDATE patches existing pbi', () => {
|
||||||
|
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const }
|
||||||
|
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
|
||||||
|
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' })
|
||||||
|
expect(useBacklogStore.getState().pbis[0].title).toBe('New')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyChange: pbi DELETE removes pbi', () => {
|
||||||
|
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
|
||||||
|
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
|
||||||
|
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
|
||||||
|
expect(useBacklogStore.getState().pbis).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyChange: story INSERT adds to storiesByPbi', () => {
|
||||||
|
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
|
||||||
|
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() }
|
||||||
|
useBacklogStore.getState().applyChange('story', 'I', story)
|
||||||
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyChange: story DELETE removes from correct pbi bucket', () => {
|
||||||
|
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() }
|
||||||
|
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} })
|
||||||
|
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
|
||||||
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyChange: task UPDATE patches task across story buckets', () => {
|
||||||
|
const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() }
|
||||||
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } })
|
||||||
|
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' })
|
||||||
|
expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
__tests__/components/backlog/backlog-split-pane.test.tsx
Normal file
85
__tests__/components/backlog/backlog-split-pane.test.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
|
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||||
|
|
||||||
|
const PANES = [
|
||||||
|
<div key="a">PBI pane</div>,
|
||||||
|
<div key="b">Stories pane</div>,
|
||||||
|
<div key="c">Tasks pane</div>,
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPane() {
|
||||||
|
return render(
|
||||||
|
<BacklogSplitPane
|
||||||
|
panes={PANES}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-backlog"
|
||||||
|
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
|
||||||
|
// Force mobile viewport
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BacklogSplitPane auto-switch', () => {
|
||||||
|
it('starts on tab 0 with no selection', () => {
|
||||||
|
renderPane()
|
||||||
|
expect(screen.getByText('PBI pane')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('Stories pane')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-switches to tab 1 when PBI is selected', () => {
|
||||||
|
const { rerender } = renderPane()
|
||||||
|
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null })
|
||||||
|
rerender(
|
||||||
|
<BacklogSplitPane
|
||||||
|
panes={PANES}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-backlog"
|
||||||
|
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Stories pane')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('PBI pane')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-switches to tab 2 when story is selected', () => {
|
||||||
|
const { rerender } = renderPane()
|
||||||
|
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
|
||||||
|
rerender(
|
||||||
|
<BacklogSplitPane
|
||||||
|
panes={PANES}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-backlog"
|
||||||
|
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Tasks pane')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('PBI pane')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
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' })
|
||||||
|
const { rerender } = renderPane()
|
||||||
|
|
||||||
|
// Cascade-reset: new PBI → story clears
|
||||||
|
useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null })
|
||||||
|
rerender(
|
||||||
|
<BacklogSplitPane
|
||||||
|
panes={PANES}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-backlog"
|
||||||
|
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Stories pane')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
133
__tests__/components/backlog/integration.test.tsx
Normal file
133
__tests__/components/backlog/integration.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// @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'
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = vi.fn()
|
||||||
|
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
||||||
|
|
||||||
|
// localStorage mock for StoryPanel sort mode persistence
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => store[k] ?? null,
|
||||||
|
setItem: (k: string, v: string) => { store[k] = v },
|
||||||
|
removeItem: (k: string) => { delete store[k] },
|
||||||
|
clear: () => { store = {} },
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||||
|
|
||||||
|
// Mock server actions
|
||||||
|
vi.mock('@/actions/stories', () => ({
|
||||||
|
reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
reorderPbisAction: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||||
|
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||||
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||||
|
|
||||||
|
// Mock dnd-kit
|
||||||
|
vi.mock('@dnd-kit/core', () => ({
|
||||||
|
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
PointerSensor: class {},
|
||||||
|
KeyboardSensor: class {},
|
||||||
|
useSensor: vi.fn(),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
closestCenter: vi.fn(),
|
||||||
|
DragOverlay: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@dnd-kit/sortable', () => ({
|
||||||
|
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useSortable: () => ({
|
||||||
|
attributes: {}, listeners: {}, setNodeRef: vi.fn(),
|
||||||
|
transform: null, transition: undefined, isDragging: false,
|
||||||
|
}),
|
||||||
|
verticalListSortingStrategy: {},
|
||||||
|
rectSortingStrategy: {},
|
||||||
|
sortableKeyboardCoordinates: {},
|
||||||
|
arrayMove: (arr: unknown[]) => arr,
|
||||||
|
}))
|
||||||
|
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
|
||||||
|
|
||||||
|
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||||
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||||
|
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
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, created_at: new Date() },
|
||||||
|
]
|
||||||
|
const TASKS = [
|
||||||
|
{ 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Backlog 3-pane integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockClear()
|
||||||
|
resetStores()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('StoryPanel shows empty state when no PBI selected', () => {
|
||||||
|
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||||
|
expect(screen.getByText('Selecteer een PBI om de stories te bekijken.')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('StoryPanel shows stories when PBI is selected', () => {
|
||||||
|
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
|
||||||
|
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 })
|
||||||
|
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||||
|
fireEvent.click(screen.getByText('Eerste story'))
|
||||||
|
expect(useSelectionStore.getState().selectedStoryId).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('TaskPanel shows tasks after story is selected', () => {
|
||||||
|
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: 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 })
|
||||||
|
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
|
||||||
|
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 })
|
||||||
|
const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||||
|
// bg-primary-container is applied when isSelected
|
||||||
|
const selected = container.querySelector('.bg-primary-container')
|
||||||
|
expect(selected).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
136
__tests__/components/backlog/task-panel.test.tsx
Normal file
136
__tests__/components/backlog/task-panel.test.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// @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'
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = vi.fn()
|
||||||
|
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
||||||
|
|
||||||
|
// Mock reorderTasksAction
|
||||||
|
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||||
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||||
|
|
||||||
|
// Mock dnd-kit to avoid jsdom drag complexity
|
||||||
|
vi.mock('@dnd-kit/core', () => ({
|
||||||
|
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
PointerSensor: class {},
|
||||||
|
KeyboardSensor: class {},
|
||||||
|
useSensor: vi.fn(),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
closestCenter: vi.fn(),
|
||||||
|
DragOverlay: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@dnd-kit/sortable', () => ({
|
||||||
|
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useSortable: () => ({
|
||||||
|
attributes: {}, listeners: {}, setNodeRef: vi.fn(),
|
||||||
|
transform: null, transition: undefined, isDragging: false,
|
||||||
|
}),
|
||||||
|
rectSortingStrategy: {},
|
||||||
|
sortableKeyboardCoordinates: {},
|
||||||
|
arrayMove: (arr: unknown[], from: number, to: number) => {
|
||||||
|
const next = [...arr]
|
||||||
|
next.splice(from, 1)
|
||||||
|
next.splice(to, 0, arr[from])
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
|
||||||
|
|
||||||
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||||
|
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
const STORY_ID = 'story-1'
|
||||||
|
const CLOSE_PATH = `/products/${PRODUCT_ID}`
|
||||||
|
|
||||||
|
const TASKS = [
|
||||||
|
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||||
|
{ id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPanel(isDemo = false) {
|
||||||
|
return render(<TaskPanel productId={PRODUCT_ID} isDemo={isDemo} closePath={CLOSE_PATH} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockClear()
|
||||||
|
useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null })
|
||||||
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no story is selected', () => {
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText('Selecteer een story om de taken te bekijken.')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state with action when story selected but no tasks', () => {
|
||||||
|
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||||
|
useBacklogStore.setState({ tasksByStory: { [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 } })
|
||||||
|
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 } })
|
||||||
|
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 } })
|
||||||
|
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]: [] } })
|
||||||
|
renderPanel()
|
||||||
|
const buttons = screen.getAllByText('+ Nieuwe taak')
|
||||||
|
fireEvent.click(buttons[0])
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?newTask=1&storyId=${STORY_ID}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking task card calls router.push with editTask param', () => {
|
||||||
|
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||||
|
useBacklogStore.setState({ tasksByStory: { [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]: [] } })
|
||||||
|
renderPanel(true)
|
||||||
|
const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button')
|
||||||
|
expect(btn).toBeTruthy()
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 } })
|
||||||
|
// 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)
|
||||||
|
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Tweede taak')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
227
__tests__/components/split-pane.test.tsx
Normal file
227
__tests__/components/split-pane.test.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||||
|
|
||||||
|
// Helper to set a cookie
|
||||||
|
function setCookie(key: string, value: string) {
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: `sp:${key}=${encodeURIComponent(value)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookies() {
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SplitPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCookies()
|
||||||
|
// Default: desktop viewport
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 2 panes', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">Pane A</div>, <div key="b">Pane B</div>]}
|
||||||
|
defaultSplit={[30, 70]}
|
||||||
|
cookieKey="test-2pane"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Pane A')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Pane B')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 3 panes with 2 dividers', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[
|
||||||
|
<div key="a">Left</div>,
|
||||||
|
<div key="b">Middle</div>,
|
||||||
|
<div key="c">Right</div>,
|
||||||
|
]}
|
||||||
|
defaultSplit={[28, 35, 37]}
|
||||||
|
cookieKey="test-3pane"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Left')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Middle')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Right')).toBeTruthy()
|
||||||
|
// 2 dividers: cursor-col-resize elements
|
||||||
|
const dividers = container.querySelectorAll('.cursor-col-resize')
|
||||||
|
expect(dividers).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores splits from cookie on mount', () => {
|
||||||
|
const stored = JSON.stringify([40, 60])
|
||||||
|
setCookie('test-restore', stored)
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
||||||
|
defaultSplit={[20, 80]}
|
||||||
|
cookieKey="test-restore"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Left pane should have width 40%, not the default 20%
|
||||||
|
const paneDiv = container.querySelector<HTMLElement>('[style*="40%"]')
|
||||||
|
expect(paneDiv).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaultSplit when cookie is invalid', () => {
|
||||||
|
setCookie('test-invalid', 'not-valid-json')
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
||||||
|
defaultSplit={[25, 75]}
|
||||||
|
cookieKey="test-invalid"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const paneDiv = container.querySelector<HTMLElement>('[style*="25%"]')
|
||||||
|
expect(paneDiv).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders tabs on mobile viewport', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 768 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
|
||||||
|
defaultSplit={[50, 50]}
|
||||||
|
cookieKey="test-mobile"
|
||||||
|
tabLabels={['Tab A', 'Tab B']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Tab A')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Tab B')).toBeTruthy()
|
||||||
|
// Only first tab content visible by default
|
||||||
|
expect(screen.getByText('Content A')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('Content B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches tab content on mobile', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
|
||||||
|
defaultSplit={[50, 50]}
|
||||||
|
cookieKey="test-mobile-switch"
|
||||||
|
tabLabels={['Tab A', 'Tab B']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Click second tab
|
||||||
|
fireEvent.click(screen.getByText('Tab B'))
|
||||||
|
expect(screen.queryByText('Content A')).toBeNull()
|
||||||
|
expect(screen.getByText('Content B')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('back button not visible on tab 0 in mobile', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-back-hidden"
|
||||||
|
tabLabels={['T1', 'T2', 'T3']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// On tab 0, no back button
|
||||||
|
expect(screen.queryByLabelText('Terug')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('back button visible on tab > 0 and navigates back', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-back-nav"
|
||||||
|
tabLabels={['T1', 'T2', 'T3']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to tab 2
|
||||||
|
fireEvent.click(screen.getByText('T3'))
|
||||||
|
expect(screen.getByText('C')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Terug')).toBeTruthy()
|
||||||
|
|
||||||
|
// Click back → tab 1
|
||||||
|
fireEvent.click(screen.getByLabelText('Terug'))
|
||||||
|
expect(screen.getByText('B')).toBeTruthy()
|
||||||
|
|
||||||
|
// Click back again → tab 0, no back button
|
||||||
|
fireEvent.click(screen.getByLabelText('Terug'))
|
||||||
|
expect(screen.getByText('A')).toBeTruthy()
|
||||||
|
expect(screen.queryByLabelText('Terug')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('controlled activeTab prop switches the active pane', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-controlled"
|
||||||
|
tabLabels={['T1', 'T2', 'T3']}
|
||||||
|
activeTab={0}
|
||||||
|
onActiveTabChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('A')).toBeTruthy()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||||
|
defaultSplit={[33, 33, 34]}
|
||||||
|
cookieKey="test-controlled"
|
||||||
|
tabLabels={['T1', 'T2', 'T3']}
|
||||||
|
activeTab={2}
|
||||||
|
onActiveTabChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('C')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render dividers on mobile', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
||||||
|
defaultSplit={[50, 50]}
|
||||||
|
cookieKey="test-no-dividers"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const dividers = container.querySelectorAll('.cursor-col-resize')
|
||||||
|
expect(dividers).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,22 +1,31 @@
|
||||||
|
import { Suspense } from 'react'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getAccessibleProduct } from '@/lib/product-access'
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { pbiStatusToApi } from '@/lib/task-status'
|
import { pbiStatusToApi } from '@/lib/task-status'
|
||||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||||
import { PbiList } from '@/components/backlog/pbi-list'
|
import { PbiList } from '@/components/backlog/pbi-list'
|
||||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||||
import type { Story } from '@/components/backlog/story-panel'
|
import type { Story } from '@/components/backlog/story-panel'
|
||||||
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||||
|
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
||||||
|
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||||
|
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||||
|
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||||
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
||||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductBacklogPage({ params }: Props) {
|
export default async function ProductBacklogPage({ params, searchParams }: Props) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||||
|
const closePath = `/products/${id}`
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) redirect('/login')
|
if (!session.userId) redirect('/login')
|
||||||
|
|
||||||
|
|
@ -33,21 +42,37 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
const stories = await prisma.story.findMany({
|
const [stories, tasks] = await Promise.all([
|
||||||
where: { product_id: id },
|
prisma.story.findMany({
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
where: { product_id: id },
|
||||||
select: {
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
id: true,
|
select: {
|
||||||
code: true,
|
id: true,
|
||||||
title: true,
|
code: true,
|
||||||
description: true,
|
title: true,
|
||||||
acceptance_criteria: true,
|
description: true,
|
||||||
priority: true,
|
acceptance_criteria: true,
|
||||||
status: true,
|
priority: true,
|
||||||
pbi_id: true,
|
status: true,
|
||||||
created_at: true,
|
pbi_id: true,
|
||||||
},
|
created_at: true,
|
||||||
})
|
},
|
||||||
|
}),
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: { story: { pbi: { product_id: id } } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
sort_order: true,
|
||||||
|
story_id: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
// Group stories by PBI id
|
// Group stories by PBI id
|
||||||
const storiesByPbi: Record<string, Story[]> = {}
|
const storiesByPbi: Record<string, Story[]> = {}
|
||||||
|
|
@ -56,6 +81,13 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
storiesByPbi[story.pbi_id].push(story)
|
storiesByPbi[story.pbi_id].push(story)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group tasks by story id
|
||||||
|
const tasksByStory: Record<string, typeof tasks> = {}
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
||||||
|
tasksByStory[task.story_id].push(task)
|
||||||
|
}
|
||||||
|
|
||||||
const isDemo = session.isDemo ?? false
|
const isDemo = session.isDemo ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -90,24 +122,60 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
|
|
||||||
{/* Split pane */}
|
{/* Split pane */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<SplitPane
|
<BacklogHydrationWrapper
|
||||||
storageKey={`backlog-${id}`}
|
productId={id}
|
||||||
left={
|
initialData={{
|
||||||
<PbiList
|
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||||||
productId={id}
|
storiesByPbi,
|
||||||
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) }))}
|
tasksByStory,
|
||||||
isDemo={isDemo}
|
}}
|
||||||
/>
|
>
|
||||||
}
|
<BacklogSplitPane
|
||||||
right={
|
cookieKey={`backlog-${id}`}
|
||||||
<StoryPanel
|
defaultSplit={[20, 45, 35]}
|
||||||
productId={id}
|
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
||||||
storiesByPbi={storiesByPbi}
|
panes={[
|
||||||
isDemo={isDemo}
|
<PbiList
|
||||||
/>
|
key="pbi"
|
||||||
}
|
productId={id}
|
||||||
/>
|
isDemo={isDemo}
|
||||||
|
/>,
|
||||||
|
<StoryPanel
|
||||||
|
key="story"
|
||||||
|
productId={id}
|
||||||
|
isDemo={isDemo}
|
||||||
|
/>,
|
||||||
|
<TaskPanel
|
||||||
|
key="tasks"
|
||||||
|
productId={id}
|
||||||
|
isDemo={isDemo}
|
||||||
|
closePath={closePath}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</BacklogHydrationWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{newTask && (
|
||||||
|
<TaskDialog
|
||||||
|
storyId={storyIdParam}
|
||||||
|
productId={id}
|
||||||
|
closePath={closePath}
|
||||||
|
isDemo={isDemo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editTask && !newTask && (
|
||||||
|
<Suspense fallback={<TaskDialogSkeleton />}>
|
||||||
|
<EditTaskLoader
|
||||||
|
taskId={editTask}
|
||||||
|
userId={session.userId}
|
||||||
|
productId={id}
|
||||||
|
closePath={closePath}
|
||||||
|
isDemo={isDemo}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
app/api/realtime/backlog/route.ts
Normal file
129
app/api/realtime/backlog/route.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// SSE endpoint for the backlog 3-pane (PBI / story / task changes).
|
||||||
|
// Simpler than /api/realtime/solo — no sprint or user scoping, just product_id filter.
|
||||||
|
// Auth: iron-session cookie. Demo users may read (no 403 for demo).
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { Client } from 'pg'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 300
|
||||||
|
|
||||||
|
const CHANNEL = 'scrum4me_changes'
|
||||||
|
const HEARTBEAT_MS = 25_000
|
||||||
|
const HARD_CLOSE_MS = 240_000
|
||||||
|
|
||||||
|
type NotifyPayload = Record<string, unknown>
|
||||||
|
|
||||||
|
function shouldEmit(payload: NotifyPayload, productId: string): boolean {
|
||||||
|
if ('type' in payload) return false // job / worker events — not relevant here
|
||||||
|
const entity = payload.entity as string | undefined
|
||||||
|
if (!entity || !['pbi', 'story', 'task'].includes(entity)) return false
|
||||||
|
return payload.product_id === productId
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) {
|
||||||
|
return Response.json({ error: 'Niet ingelogd' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = request.nextUrl.searchParams.get('product_id')
|
||||||
|
if (!productId) {
|
||||||
|
return Response.json({ error: 'product_id is verplicht' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await getAccessibleProduct(productId, session.userId)
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
|
||||||
|
if (!directUrl) {
|
||||||
|
return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const pgClient = new Client({ connectionString: directUrl })
|
||||||
|
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let hardCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let closed = false
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const enqueue = (chunk: string) => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(chunk))
|
||||||
|
} catch {
|
||||||
|
// stream already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async (reason: string) => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
||||||
|
if (hardCloseTimer) clearTimeout(hardCloseTimer)
|
||||||
|
try { await pgClient.end() } catch { /* ignore */ }
|
||||||
|
try { controller.close() } catch { /* already closed */ }
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log(`[realtime/backlog] closed: ${reason}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pgClient.connect()
|
||||||
|
await pgClient.query(`LISTEN ${CHANNEL}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[realtime/backlog] pg connect/listen failed:', err)
|
||||||
|
enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`)
|
||||||
|
await cleanup('pg connect failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pgClient.on('notification', (msg) => {
|
||||||
|
if (!msg.payload) return
|
||||||
|
let payload: NotifyPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(msg.payload) as NotifyPayload
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!shouldEmit(payload, productId)) return
|
||||||
|
enqueue(`data: ${msg.payload}\n\n`)
|
||||||
|
})
|
||||||
|
|
||||||
|
pgClient.on('error', async (err) => {
|
||||||
|
console.error('[realtime/backlog] pg client error:', err)
|
||||||
|
await cleanup('pg error')
|
||||||
|
})
|
||||||
|
|
||||||
|
enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`)
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
enqueue(`: heartbeat\n\n`)
|
||||||
|
}, HEARTBEAT_MS)
|
||||||
|
|
||||||
|
hardCloseTimer = setTimeout(() => {
|
||||||
|
cleanup('hard close 240s')
|
||||||
|
}, HARD_CLOSE_MS)
|
||||||
|
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
cleanup('client aborted')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
30
components/backlog/backlog-hydration-wrapper.tsx
Normal file
30
components/backlog/backlog-hydration-wrapper.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store'
|
||||||
|
import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime'
|
||||||
|
|
||||||
|
interface InitialData {
|
||||||
|
pbis: BacklogPbi[]
|
||||||
|
storiesByPbi: Record<string, BacklogStory[]>
|
||||||
|
tasksByStory: Record<string, BacklogTask[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacklogHydrationWrapperProps {
|
||||||
|
initialData: InitialData
|
||||||
|
productId: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) {
|
||||||
|
const setInitialData = useBacklogStore((s) => s.setInitialData)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInitialData(initialData)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useBacklogRealtime(productId)
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
34
components/backlog/backlog-split-pane.tsx
Normal file
34
components/backlog/backlog-split-pane.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
|
import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane'
|
||||||
|
|
||||||
|
type Props = Omit<SplitPaneProps, 'activeTab' | 'onActiveTabChange'>
|
||||||
|
|
||||||
|
export function BacklogSplitPane(props: Props) {
|
||||||
|
const { selectedPbiId, selectedStoryId } = useSelectionStore()
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
|
|
||||||
|
// React-recommended "derived state from props" pattern: update state during render
|
||||||
|
// instead of useEffect to avoid cascading renders.
|
||||||
|
const [prevPbiId, setPrevPbiId] = useState(selectedPbiId)
|
||||||
|
const [prevStoryId, setPrevStoryId] = useState(selectedStoryId)
|
||||||
|
|
||||||
|
if (selectedStoryId !== prevStoryId) {
|
||||||
|
setPrevStoryId(selectedStoryId)
|
||||||
|
if (selectedStoryId) setActiveTab(2)
|
||||||
|
}
|
||||||
|
if (selectedPbiId !== prevPbiId) {
|
||||||
|
setPrevPbiId(selectedPbiId)
|
||||||
|
if (selectedPbiId && !selectedStoryId) setActiveTab(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitPane
|
||||||
|
{...props}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onActiveTabChange={setActiveTab}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
components/backlog/empty-panel.tsx
Normal file
35
components/backlog/empty-panel.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
|
|
||||||
|
interface EmptyPanelProps {
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
action?: {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyPanel({ title, message, action }: EmptyPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-muted-foreground space-y-3">
|
||||||
|
{title && <p className="text-sm font-medium text-foreground">{title}</p>}
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
{action && (
|
||||||
|
<DemoTooltip show={action.disabled ?? false}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={action.disabled ? undefined : action.onClick}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -27,11 +27,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
import { useSelectionStore } from '@/stores/selection-store'
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
import { usePlannerStore } from '@/stores/planner-store'
|
import { usePlannerStore } from '@/stores/planner-store'
|
||||||
|
import { useBacklogStore } from '@/stores/backlog-store'
|
||||||
import { deletePbiAction } from '@/actions/pbis'
|
import { deletePbiAction } from '@/actions/pbis'
|
||||||
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { EmptyPanel } from './empty-panel'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||||
|
|
@ -115,7 +117,6 @@ interface Pbi {
|
||||||
|
|
||||||
interface PbiListProps {
|
interface PbiListProps {
|
||||||
productId: string
|
productId: string
|
||||||
pbis: Pbi[]
|
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,7 +195,8 @@ function SortablePbiRow({
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main component ---
|
// --- Main component ---
|
||||||
export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
|
const pbis = useBacklogStore((s) => s.pbis)
|
||||||
const { selectedPbiId, selectPbi } = useSelectionStore()
|
const { selectedPbiId, selectPbi } = useSelectionStore()
|
||||||
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
||||||
// Defaults match SSR; persisted values applied post-mount in the loader effect below.
|
// Defaults match SSR; persisted values applied post-mount in the loader effect below.
|
||||||
|
|
@ -417,14 +419,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{pbis.length === 0 ? (
|
{pbis.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground text-sm space-y-3">
|
<EmptyPanel
|
||||||
<p>Nog geen PBI's aangemaakt.</p>
|
message="Nog geen PBI's aangemaakt."
|
||||||
<DemoTooltip show={isDemo}>
|
action={{ label: 'Maak je eerste PBI aan', onClick: () => setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }}
|
||||||
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
|
/>
|
||||||
Maak je eerste PBI aan
|
|
||||||
</Button>
|
|
||||||
</DemoTooltip>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
id="pbi-list"
|
id="pbi-list"
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
import { useSelectionStore } from '@/stores/selection-store'
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
import { usePlannerStore } from '@/stores/planner-store'
|
import { usePlannerStore } from '@/stores/planner-store'
|
||||||
|
import { useBacklogStore } from '@/stores/backlog-store'
|
||||||
import { reorderStoriesAction } from '@/actions/stories'
|
import { reorderStoriesAction } from '@/actions/stories'
|
||||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { EmptyPanel } from './empty-panel'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -59,17 +61,20 @@ export interface Story {
|
||||||
|
|
||||||
interface StoryPanelProps {
|
interface StoryPanelProps {
|
||||||
productId: string
|
productId: string
|
||||||
storiesByPbi: Record<string, Story[]>
|
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sortable story block ---
|
// --- Sortable story block ---
|
||||||
function SortableStoryBlock({
|
function SortableStoryBlock({
|
||||||
story,
|
story,
|
||||||
onClick,
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
story: Story
|
story: Story
|
||||||
onClick: () => void
|
isSelected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onEdit: () => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: story.id,
|
id: story.id,
|
||||||
|
|
@ -91,19 +96,33 @@ function SortableStoryBlock({
|
||||||
code={story.code}
|
code={story.code}
|
||||||
priority={story.priority}
|
priority={story.priority}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
onClick={onClick}
|
isSelected={isSelected}
|
||||||
|
onClick={onSelect}
|
||||||
badge={
|
badge={
|
||||||
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
|
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
|
||||||
{STATUS_LABELS[story.status] ?? story.status}
|
{STATUS_LABELS[story.status] ?? story.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||||||
|
className="text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
|
||||||
|
aria-label="Story bewerken"
|
||||||
|
>
|
||||||
|
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main component ---
|
// --- Main component ---
|
||||||
export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) {
|
export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
||||||
const { selectedPbiId } = useSelectionStore()
|
const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore()
|
||||||
|
const storiesByPbi = useBacklogStore((s) => s.storiesByPbi)
|
||||||
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
|
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
|
||||||
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
||||||
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
||||||
|
|
@ -242,20 +261,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{selectedPbiId === null ? (
|
{selectedPbiId === null ? (
|
||||||
<p className="text-sm text-muted-foreground text-center mt-8">
|
<EmptyPanel message="Selecteer een PBI om de stories te bekijken." />
|
||||||
Selecteer een PBI om de stories te bekijken.
|
|
||||||
</p>
|
|
||||||
) : rawStories.length === 0 ? (
|
) : rawStories.length === 0 ? (
|
||||||
<div className="text-center mt-8 space-y-3">
|
<EmptyPanel
|
||||||
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
|
message="Nog geen stories voor dit PBI."
|
||||||
{selectedPbiId && (
|
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
|
||||||
<DemoTooltip show={isDemo}>
|
/>
|
||||||
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
|
|
||||||
Maak je eerste story aan
|
|
||||||
</Button>
|
|
||||||
</DemoTooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
id="story-panel"
|
id="story-panel"
|
||||||
|
|
@ -270,7 +281,9 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
<SortableStoryBlock
|
<SortableStoryBlock
|
||||||
key={story.id}
|
key={story.id}
|
||||||
story={story}
|
story={story}
|
||||||
onClick={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
isSelected={selectedStoryId === story.id}
|
||||||
|
onSelect={() => selectStory(story.id)}
|
||||||
|
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
225
components/backlog/task-panel.tsx
Normal file
225
components/backlog/task-panel.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
rectSortingStrategy,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
|
import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store'
|
||||||
|
import { reorderTasksAction } from '@/actions/tasks'
|
||||||
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { EmptyPanel } from './empty-panel'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||||
|
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||||
|
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
|
||||||
|
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||||
|
}
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
TO_DO: 'To Do',
|
||||||
|
IN_PROGRESS: 'Bezig',
|
||||||
|
REVIEW: 'Review',
|
||||||
|
DONE: 'Klaar',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableTaskCard({
|
||||||
|
task,
|
||||||
|
isDemo,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
task: BacklogTask
|
||||||
|
isDemo: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: task.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BacklogCard
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...(isDemo ? {} : listeners)}
|
||||||
|
title={task.title}
|
||||||
|
priority={task.priority}
|
||||||
|
isDragging={isDragging}
|
||||||
|
onClick={onClick}
|
||||||
|
badge={
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0 border',
|
||||||
|
STATUS_COLORS[task.status] ?? STATUS_COLORS.TO_DO,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[task.status] ?? task.status}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskPanelProps {
|
||||||
|
productId: string
|
||||||
|
isDemo: boolean
|
||||||
|
closePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [, startTransition] = useTransition()
|
||||||
|
const selectedStoryId = useSelectionStore((s) => s.selectedStoryId)
|
||||||
|
const tasksByStory = useBacklogStore((s) => s.tasksByStory)
|
||||||
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
|
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
|
||||||
|
|
||||||
|
const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null
|
||||||
|
|
||||||
|
// Merge local order with rawTasks for optimistic reorder
|
||||||
|
const tasks: BacklogTask[] | null = rawTasks === null
|
||||||
|
? null
|
||||||
|
: localOrder
|
||||||
|
? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[]
|
||||||
|
: rawTasks
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
setActiveDragId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveDragId(null)
|
||||||
|
if (!selectedStoryId || !tasks) return
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const ids = tasks.map((t) => t.id)
|
||||||
|
const oldIndex = ids.indexOf(active.id as string)
|
||||||
|
const newIndex = ids.indexOf(over.id as string)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const newOrder = arrayMove(ids, oldIndex, newIndex)
|
||||||
|
setLocalOrder(newOrder)
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await reorderTasksAction(selectedStoryId, newOrder)
|
||||||
|
if (result?.error) {
|
||||||
|
setLocalOrder(null)
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const navActions = (
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={isDemo || !selectedStoryId}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedStoryId) return
|
||||||
|
router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nieuwe taak
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tasks === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PanelNavBar title="Taken" actions={navActions} />
|
||||||
|
<EmptyPanel message="Selecteer een story om de taken te bekijken." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PanelNavBar title="Taken" actions={navActions} />
|
||||||
|
<EmptyPanel
|
||||||
|
message="Nog geen taken voor deze story."
|
||||||
|
action={{
|
||||||
|
label: 'Nieuwe taak',
|
||||||
|
onClick: () => router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`),
|
||||||
|
disabled: isDemo,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PanelNavBar title="Taken" actions={navActions} />
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<DndContext
|
||||||
|
id="task-panel"
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<SortableTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeTask && (
|
||||||
|
<BacklogCard
|
||||||
|
title={activeTask.title}
|
||||||
|
priority={activeTask.priority}
|
||||||
|
className="border-primary shadow-xl opacity-90"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,70 +1,117 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { Fragment, useRef, useState, useEffect, useCallback } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const COOKIE_PREFIX = 'split-pane:'
|
const COOKIE_PREFIX = 'sp:'
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
||||||
|
|
||||||
function readSplitCookie(key: string): number | null {
|
function readSplits(cookieKey: string, n: number): number[] | null {
|
||||||
if (typeof document === 'undefined') return null
|
if (typeof document === 'undefined') return null
|
||||||
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_PREFIX}${key}=([^;]+)`))
|
const match = document.cookie.match(
|
||||||
|
new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`)
|
||||||
|
)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
const val = parseFloat(decodeURIComponent(match[1]))
|
try {
|
||||||
return !isNaN(val) && val > 0 && val < 100 ? val : null
|
const parsed: unknown = JSON.parse(decodeURIComponent(match[1]))
|
||||||
|
if (
|
||||||
|
!Array.isArray(parsed) ||
|
||||||
|
parsed.length !== n ||
|
||||||
|
parsed.some((v) => typeof v !== 'number') ||
|
||||||
|
Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1
|
||||||
|
) return null
|
||||||
|
return parsed as number[]
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeSplitCookie(key: string, value: number) {
|
function writeSplits(cookieKey: string, splits: number[]) {
|
||||||
document.cookie = `${COOKIE_PREFIX}${key}=${value}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax`
|
document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent(
|
||||||
|
JSON.stringify(splits)
|
||||||
|
)}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitPaneProps {
|
export interface SplitPaneProps {
|
||||||
left: React.ReactNode
|
panes: React.ReactNode[]
|
||||||
right: React.ReactNode
|
defaultSplit: number[] // length n, values sum to 100
|
||||||
storageKey: string
|
cookieKey: string
|
||||||
defaultSplit?: number // percentage for left pane
|
tabLabels?: string[] // mobile tab labels, defaults to "Pane N"
|
||||||
minSize?: number // minimum px per pane, default 200
|
minSize?: number // minimum px per pane, default 200
|
||||||
|
mobileBreakpoint?: number // default 1024
|
||||||
|
activeTab?: number // controlled: parent manages which tab is active
|
||||||
|
onActiveTabChange?: (index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplitPane({
|
export function SplitPane({
|
||||||
left,
|
panes,
|
||||||
right,
|
defaultSplit,
|
||||||
storageKey,
|
cookieKey,
|
||||||
defaultSplit = 20,
|
tabLabels,
|
||||||
minSize = 200,
|
minSize = 200,
|
||||||
|
mobileBreakpoint = 1024,
|
||||||
|
activeTab: activeTabProp,
|
||||||
|
onActiveTabChange,
|
||||||
}: SplitPaneProps) {
|
}: SplitPaneProps) {
|
||||||
|
const isControlled = activeTabProp !== undefined
|
||||||
|
const n = panes.length
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [split, setSplit] = useState<number>(() => {
|
const splitsRef = useRef<number[]>(defaultSplit)
|
||||||
return readSplitCookie(storageKey) ?? defaultSplit
|
|
||||||
})
|
const [splits, setSplits] = useState<number[]>(() => {
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
return readSplits(cookieKey, n) ?? defaultSplit
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
})
|
||||||
const [activeTab, setActiveTab] = useState<'left' | 'right'>('left')
|
const [dragging, setDragging] = useState<number | null>(null) // divider index (0..n-2)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [internalTab, setInternalTab] = useState(0)
|
||||||
|
const activeTab = isControlled ? activeTabProp : internalTab
|
||||||
|
|
||||||
|
const handleTabChange = (i: number) => {
|
||||||
|
if (!isControlled) setInternalTab(i)
|
||||||
|
onActiveTabChange?.(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { splitsRef.current = splits }, [splits])
|
||||||
|
|
||||||
// Detect mobile
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsMobile(window.innerWidth < 1024)
|
const check = () => setIsMobile(window.innerWidth < mobileBreakpoint)
|
||||||
check()
|
check()
|
||||||
window.addEventListener('resize', check)
|
window.addEventListener('resize', check)
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [mobileBreakpoint])
|
||||||
|
|
||||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
const onMouseMove = useCallback((e: MouseEvent) => {
|
||||||
if (!isDragging || !containerRef.current) return
|
if (dragging === null || !containerRef.current) return
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
const containerWidth = rect.width
|
const containerWidth = rect.width
|
||||||
const offsetX = e.clientX - rect.left
|
|
||||||
const minPct = (minSize / containerWidth) * 100
|
const minPct = (minSize / containerWidth) * 100
|
||||||
const maxPct = 100 - minPct
|
|
||||||
const newSplit = Math.min(maxPct, Math.max(minPct, (offsetX / containerWidth) * 100))
|
|
||||||
setSplit(newSplit)
|
|
||||||
writeSplitCookie(storageKey, newSplit)
|
|
||||||
}, [isDragging, minSize, storageKey])
|
|
||||||
|
|
||||||
const onMouseUp = useCallback(() => setIsDragging(false), [])
|
const cursorPct = ((e.clientX - rect.left) / containerWidth) * 100
|
||||||
|
const current = splitsRef.current
|
||||||
|
// Left edge of pane[dragging] in percentage
|
||||||
|
const leftEdge = current.slice(0, dragging).reduce((a, b) => a + b, 0)
|
||||||
|
const combinedWidth = current[dragging] + current[dragging + 1]
|
||||||
|
|
||||||
|
const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct)
|
||||||
|
const newRight = combinedWidth - newLeft
|
||||||
|
|
||||||
|
setSplits((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[dragging] = newLeft
|
||||||
|
next[dragging + 1] = newRight
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [dragging, minSize])
|
||||||
|
|
||||||
|
const onMouseUp = useCallback(() => {
|
||||||
|
if (dragging !== null) {
|
||||||
|
writeSplits(cookieKey, splitsRef.current)
|
||||||
|
setDragging(null)
|
||||||
|
}
|
||||||
|
}, [dragging, cookieKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging) {
|
if (dragging !== null) {
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
}
|
}
|
||||||
|
|
@ -72,37 +119,38 @@ export function SplitPane({
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
}
|
}
|
||||||
}, [isDragging, onMouseMove, onMouseUp])
|
}, [dragging, onMouseMove, onMouseUp])
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex border-b border-border shrink-0">
|
<div className="flex items-center border-b border-border shrink-0">
|
||||||
<button
|
{activeTab > 0 && (
|
||||||
onClick={() => setActiveTab('left')}
|
<button
|
||||||
className={cn(
|
onClick={() => handleTabChange(activeTab - 1)}
|
||||||
'flex-1 py-2 text-sm font-medium transition-colors',
|
className="px-3 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||||
activeTab === 'left'
|
aria-label="Terug"
|
||||||
? 'border-b-2 border-primary text-primary'
|
>
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
←
|
||||||
)}
|
</button>
|
||||||
>
|
)}
|
||||||
Backlog
|
{panes.map((_, i) => (
|
||||||
</button>
|
<button
|
||||||
<button
|
key={i}
|
||||||
onClick={() => setActiveTab('right')}
|
onClick={() => handleTabChange(i)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 py-2 text-sm font-medium transition-colors',
|
'flex-1 py-2 text-sm font-medium transition-colors',
|
||||||
activeTab === 'right'
|
activeTab === i
|
||||||
? 'border-b-2 border-primary text-primary'
|
? 'border-b-2 border-primary text-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Stories
|
{tabLabels?.[i] ?? `Pane ${i + 1}`}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{activeTab === 'left' ? left : right}
|
{panes[activeTab]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -110,24 +158,25 @@ export function SplitPane({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex h-full overflow-hidden select-none">
|
<div ref={containerRef} className="flex h-full overflow-hidden select-none">
|
||||||
{/* Left pane */}
|
{panes.map((pane, i) => (
|
||||||
<div className="flex flex-col overflow-hidden" style={{ width: `${split}%` }}>
|
<Fragment key={i}>
|
||||||
{left}
|
{i > 0 && (
|
||||||
</div>
|
<div
|
||||||
|
onMouseDown={() => setDragging(i - 1)}
|
||||||
{/* Divider */}
|
className={cn(
|
||||||
<div
|
'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize',
|
||||||
onMouseDown={() => setIsDragging(true)}
|
dragging === i - 1 && 'bg-primary'
|
||||||
className={cn(
|
)}
|
||||||
'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize',
|
/>
|
||||||
isDragging && 'bg-primary'
|
)}
|
||||||
)}
|
<div
|
||||||
/>
|
className="flex flex-col overflow-hidden"
|
||||||
|
style={i === n - 1 ? { flex: 1 } : { width: `${splits[i]}%` }}
|
||||||
{/* Right pane */}
|
>
|
||||||
<div className="flex flex-col overflow-hidden flex-1">
|
{pane}
|
||||||
{right}
|
</div>
|
||||||
</div>
|
</Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface TriplePaneProps {
|
|
||||||
left: React.ReactNode
|
|
||||||
middle: React.ReactNode
|
|
||||||
right: React.ReactNode
|
|
||||||
storageKey: string
|
|
||||||
defaultLeft?: number // % width for left pane
|
|
||||||
defaultMiddle?: number // % width for middle pane, right gets the rest
|
|
||||||
minSize?: number // minimum px per pane
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TriplePane({
|
|
||||||
left, middle, right, storageKey,
|
|
||||||
defaultLeft = 28, defaultMiddle = 35, minSize = 180,
|
|
||||||
}: TriplePaneProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const load = (key: string, def: number) => {
|
|
||||||
if (typeof window === 'undefined') return def
|
|
||||||
const stored = localStorage.getItem(`triple-pane:${storageKey}:${key}`)
|
|
||||||
if (stored) {
|
|
||||||
const val = parseFloat(stored)
|
|
||||||
if (!isNaN(val) && val > 0 && val < 100) return val
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
const [leftPct, setLeftPct] = useState(() => load('left', defaultLeft))
|
|
||||||
const [midPct, setMidPct] = useState(() => load('mid', defaultMiddle))
|
|
||||||
const [dragging, setDragging] = useState<'left' | 'right' | null>(null)
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
|
||||||
const [activeTab, setActiveTab] = useState<'left' | 'middle' | 'right'>('left')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const check = () => setIsMobile(window.innerWidth < 1024)
|
|
||||||
check()
|
|
||||||
window.addEventListener('resize', check)
|
|
||||||
return () => window.removeEventListener('resize', check)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
if (!dragging || !containerRef.current) return
|
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
|
||||||
const width = rect.width
|
|
||||||
const minPct = (minSize / width) * 100
|
|
||||||
const offsetPct = ((e.clientX - rect.left) / width) * 100
|
|
||||||
|
|
||||||
if (dragging === 'left') {
|
|
||||||
const clamped = Math.min(Math.max(offsetPct, minPct), 100 - midPct - minPct)
|
|
||||||
setLeftPct(clamped)
|
|
||||||
localStorage.setItem(`triple-pane:${storageKey}:left`, String(clamped))
|
|
||||||
} else {
|
|
||||||
const clamped = Math.min(Math.max(offsetPct - leftPct, minPct), 100 - leftPct - minPct)
|
|
||||||
setMidPct(clamped)
|
|
||||||
localStorage.setItem(`triple-pane:${storageKey}:mid`, String(clamped))
|
|
||||||
}
|
|
||||||
}, [dragging, leftPct, midPct, minSize, storageKey])
|
|
||||||
|
|
||||||
const onMouseUp = useCallback(() => setDragging(null), [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dragging) {
|
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
}, [dragging, onMouseMove, onMouseUp])
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
const tabs = ['left', 'middle', 'right'] as const
|
|
||||||
const labels = ['Backlog', 'Sprint', 'Taken']
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex border-b border-border shrink-0">
|
|
||||||
{tabs.map((tab, i) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 py-2 text-xs font-medium transition-colors',
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-b-2 border-primary text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{labels[i]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{activeTab === 'left' ? left : activeTab === 'middle' ? middle : right}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rightPct = 100 - leftPct - midPct
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="flex h-full overflow-hidden select-none">
|
|
||||||
<div className="flex flex-col overflow-hidden" style={{ width: `${leftPct}%` }}>
|
|
||||||
{left}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onMouseDown={() => setDragging('left')}
|
|
||||||
className={cn(
|
|
||||||
'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize',
|
|
||||||
dragging === 'left' && 'bg-primary'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col overflow-hidden" style={{ width: `${midPct}%` }}>
|
|
||||||
{middle}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onMouseDown={() => setDragging('right')}
|
|
||||||
className={cn(
|
|
||||||
'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize',
|
|
||||||
dragging === 'right' && 'bg-primary'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col overflow-hidden" style={{ width: `${rightPct}%` }}>
|
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
|
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { TriplePane } from '@/components/split-pane/triple-pane'
|
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||||
import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog'
|
import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog'
|
||||||
import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog'
|
import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog'
|
||||||
import { TaskList } from './task-list'
|
import { TaskList } from './task-list'
|
||||||
|
|
@ -200,18 +200,20 @@ export function SprintBoardClient({
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<TriplePane
|
<SplitPane
|
||||||
storageKey={`sprint-${productId}`}
|
cookieKey={`sprint-${productId}`}
|
||||||
left={
|
defaultSplit={[28, 35, 37]}
|
||||||
|
tabLabels={['Backlog', 'Sprint', 'Taken']}
|
||||||
|
panes={[
|
||||||
<SprintBacklogRight
|
<SprintBacklogRight
|
||||||
|
key="backlog"
|
||||||
pbisWithStories={pbisWithStories}
|
pbisWithStories={pbisWithStories}
|
||||||
sprintStoryIds={sprintStoryIds}
|
sprintStoryIds={sprintStoryIds}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
/>
|
/>,
|
||||||
}
|
|
||||||
middle={
|
|
||||||
<SprintBacklogLeft
|
<SprintBacklogLeft
|
||||||
|
key="sprint"
|
||||||
sprintId={sprintId}
|
sprintId={sprintId}
|
||||||
stories={sprintStories}
|
stories={sprintStories}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
|
@ -222,11 +224,10 @@ export function SprintBoardClient({
|
||||||
productId={productId}
|
productId={productId}
|
||||||
members={members}
|
members={members}
|
||||||
onAssigneeChange={handleAssigneeChange}
|
onAssigneeChange={handleAssigneeChange}
|
||||||
/>
|
/>,
|
||||||
}
|
|
||||||
right={
|
|
||||||
selectedStoryId ? (
|
selectedStoryId ? (
|
||||||
<TaskList
|
<TaskList
|
||||||
|
key="tasks"
|
||||||
storyId={selectedStoryId}
|
storyId={selectedStoryId}
|
||||||
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
|
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
|
||||||
sprintId={sprintId}
|
sprintId={sprintId}
|
||||||
|
|
@ -235,11 +236,11 @@ export function SprintBoardClient({
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div key="tasks-empty" className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">Selecteer een story om de taken te bekijken.</p>
|
<p className="text-sm text-muted-foreground">Selecteer een story om de taken te bekijken.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
]}
|
||||||
/>
|
/>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeDragStory && (
|
{activeDragStory && (
|
||||||
|
|
|
||||||
|
|
@ -733,8 +733,9 @@ scrum4me/
|
||||||
│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid)
|
│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid)
|
||||||
│ └── env.ts # Zod-gevalideerde env vars
|
│ └── env.ts # Zod-gevalideerde env vars
|
||||||
├── stores/ # Zustand stores
|
├── stores/ # Zustand stores
|
||||||
|
│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE)
|
||||||
│ ├── planner-store.ts # Optimistische drag-and-drop volgorde
|
│ ├── planner-store.ts # Optimistische drag-and-drop volgorde
|
||||||
│ ├── selection-store.ts # Geselecteerd PBI / story
|
│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset)
|
||||||
│ ├── sprint-store.ts # Sprint Backlog taakvolgordes
|
│ ├── sprint-store.ts # Sprint Backlog taakvolgordes
|
||||||
│ ├── solo-store.ts # Solo board optimistische taakstatus
|
│ ├── solo-store.ts # Solo board optimistische taakstatus
|
||||||
│ └── product-store.ts # Actief product (naam + id) voor navbar
|
│ └── product-store.ts # Actief product (naam + id) voor navbar
|
||||||
|
|
@ -1003,6 +1004,67 @@ Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij d
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Realtime — Backlog SSE (ST-1115)
|
||||||
|
|
||||||
|
De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Mutatie (Prisma write) │ Server Action, MCP, etc.
|
||||||
|
└────────────┬────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE
|
||||||
|
│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task'
|
||||||
|
└────────────┬────────────┘
|
||||||
|
▼ pg_notify('scrum4me_changes', json)
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ /api/realtime/backlog │ Node runtime, dedicated pg.Client
|
||||||
|
│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task}
|
||||||
|
│ │ én product_id matcht query-param
|
||||||
|
└────────────┬────────────┘
|
||||||
|
▼ text/event-stream
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ EventSource (browser) │ beheerd door useBacklogRealtime
|
||||||
|
│ → backlog-store.apply │ via applyChange(entity, op, data)
|
||||||
|
│ Change(entity,op,data)│
|
||||||
|
└────────────┬────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ PbiList / StoryPanel / │ re-render op basis van Zustand state
|
||||||
|
│ TaskPanel re-render │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration en SSE-mount
|
||||||
|
|
||||||
|
De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die:
|
||||||
|
1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig).
|
||||||
|
2. `useBacklogRealtime(productId)` mount — opent de SSE-stream.
|
||||||
|
|
||||||
|
Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer.
|
||||||
|
|
||||||
|
### backlog-store en applyChange
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// stores/backlog-store.ts
|
||||||
|
applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record<string, unknown>)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array
|
||||||
|
- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`)
|
||||||
|
- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload
|
||||||
|
|
||||||
|
### Server-side filter (backlog)
|
||||||
|
|
||||||
|
`/api/realtime/backlog?product_id=...` filtert op:
|
||||||
|
- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd
|
||||||
|
- `product_id` matcht de query-param
|
||||||
|
|
||||||
|
Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Demo-user policy (ST-1110)
|
## Demo-user policy (ST-1110)
|
||||||
|
|
||||||
Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags:
|
Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags:
|
||||||
|
|
|
||||||
|
|
@ -194,27 +194,37 @@ Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-04: Product Backlog — gesplitst scherm
|
### F-04: Product Backlog — 3-paneels gesplitst scherm
|
||||||
|
|
||||||
**Prioriteit:** v1 — Kritiek
|
**Prioriteit:** v1 — Kritiek
|
||||||
**Persona:** Lars, Dina, Remi
|
**Persona:** Lars, Dina, Remi
|
||||||
|
|
||||||
**Omschrijving:**
|
**Omschrijving:**
|
||||||
De Product Backlog wordt weergegeven als een gesplitst scherm: links de PBI's gerangschikt op prioriteit en volgorde, rechts de stories van het geselecteerde PBI. De splitter is horizontaal versleepbaar. Elk paneel heeft een eigen navigatiebar met acties (aanmaken, filteren, verwijderen).
|
De Product Backlog wordt weergegeven als een 3-paneels gesplitst scherm: PBI's (links) | Stories (midden) | Taken (rechts). De splitters zijn versleepbaar. Selectie cascadeert: klikken op een PBI toont de bijbehorende stories; klikken op een story toont de bijbehorende taken. Elk paneel heeft een eigen navigatiebar met acties.
|
||||||
|
|
||||||
**Acceptatiecriteria:**
|
**Acceptatiecriteria:**
|
||||||
- [ ] Standaard splitverhouding is 40/60 (PBI's / stories)
|
- [ ] Standaard splitverhouding is 20/45/35 (PBI's / Stories / Taken)
|
||||||
- [ ] Splitter is versleepbaar; positie wordt lokaal opgeslagen (localStorage)
|
- [ ] Splitters zijn versleepbaar; positie wordt opgeslagen in een cookie (`sp:backlog-{id}`)
|
||||||
- [ ] Selecteren van een PBI links toont de bijbehorende stories rechts
|
- [ ] Selecteren van een PBI toont de bijbehorende stories in het middenpaneel
|
||||||
- [ ] Geselecteerd PBI is visueel gemarkeerd (achtergrondkleur of rand)
|
- [ ] Geselecteerd PBI is visueel gemarkeerd (achtergrondkleur of rand)
|
||||||
- [ ] Linkerpaneel navigatiebar bevat: [+ PBI aanmaken], [filter], [verwijderen]
|
- [ ] Selecteren van een story toont de bijbehorende taken in het rechterpaneel
|
||||||
- [ ] Rechterpaneel navigatiebar bevat: [+ Story aanmaken], [filter], [verwijderen]
|
- [ ] Geselecteerde story is visueel gemarkeerd
|
||||||
- [ ] Lege staat links: prompt om eerste PBI aan te maken
|
- [ ] Cascade-reset: selecteren van een ander PBI wist de geselecteerde story en taken
|
||||||
- [ ] Lege staat rechts (geen PBI geselecteerd): instructie om een PBI te selecteren
|
- [ ] PBI-paneel navigatiebar bevat: [+ PBI aanmaken]
|
||||||
- [ ] Lege staat rechts (PBI geselecteerd, geen stories): prompt om eerste story aan te maken
|
- [ ] Stories-paneel navigatiebar bevat: [+ Story aanmaken], [sorteer], [filter status]
|
||||||
|
- [ ] Taken-paneel navigatiebar bevat: [+ Nieuwe taak]
|
||||||
|
- [ ] Lege staat PBI-paneel: prompt om eerste PBI aan te maken
|
||||||
|
- [ ] Lege staat Stories-paneel (geen PBI geselecteerd): instructie om een PBI te selecteren
|
||||||
|
- [ ] Lege staat Stories-paneel (PBI geselecteerd, geen stories): prompt om eerste story aan te maken
|
||||||
|
- [ ] Lege staat Taken-paneel (geen story geselecteerd): instructie om een story te selecteren
|
||||||
|
- [ ] Lege staat Taken-paneel (story geselecteerd, geen taken): prompt om eerste taak aan te maken
|
||||||
|
- [ ] Taak aanmaken opent TaskDialog via `?newTask=1&storyId={id}`
|
||||||
|
- [ ] Taak bewerken opent TaskDialog via `?editTask={id}`
|
||||||
|
|
||||||
**Randgevallen:**
|
**Randgevallen:**
|
||||||
- Scherm smaller dan 768px → gesplitst scherm schakelt over naar tabbladen (PBI's / Stories)
|
- Scherm smaller dan 1024px → 3-paneels scherm schakelt over naar 3 tabbladen (PBI's | Stories | Taken)
|
||||||
|
- Mobile tab-navigatie: klikken op PBI schakelt automatisch naar Stories-tab; klikken op story schakelt naar Taken-tab
|
||||||
|
- Mobile ← terug-knop in tab-header op tabs 2 en 3 navigeert naar het vorige tabblad
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
92
lib/realtime/use-backlog-realtime.ts
Normal file
92
lib/realtime/use-backlog-realtime.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
// ST-1115: Client hook for the backlog 3-pane SSE stream.
|
||||||
|
// Mounts in BacklogHydrationWrapper so it survives Server Action refreshes.
|
||||||
|
// Dispatches pbi/story/task change events into useBacklogStore.applyChange.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useBacklogStore } from '@/stores/backlog-store'
|
||||||
|
|
||||||
|
const BACKOFF_START_MS = 1_000
|
||||||
|
const BACKOFF_MAX_MS = 30_000
|
||||||
|
|
||||||
|
type EntityPayload = {
|
||||||
|
op: 'I' | 'U' | 'D'
|
||||||
|
entity: 'pbi' | 'story' | 'task'
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBacklogRealtime(productId: string | null) {
|
||||||
|
const sourceRef = useRef<EventSource | null>(null)
|
||||||
|
const backoffRef = useRef<number>(BACKOFF_START_MS)
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!productId) return
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (sourceRef.current) {
|
||||||
|
sourceRef.current.close()
|
||||||
|
sourceRef.current = null
|
||||||
|
}
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current)
|
||||||
|
reconnectTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
close()
|
||||||
|
const source = new EventSource(
|
||||||
|
`/api/realtime/backlog?product_id=${encodeURIComponent(productId)}`,
|
||||||
|
)
|
||||||
|
sourceRef.current = source
|
||||||
|
|
||||||
|
source.addEventListener('ready', () => {
|
||||||
|
backoffRef.current = BACKOFF_START_MS
|
||||||
|
})
|
||||||
|
|
||||||
|
source.onmessage = (e) => {
|
||||||
|
if (!e.data) return
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.data) as EntityPayload
|
||||||
|
useBacklogStore
|
||||||
|
.getState()
|
||||||
|
.applyChange(payload.entity, payload.op, payload as Record<string, unknown>)
|
||||||
|
} catch (err) {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error('[realtime/backlog] failed to parse event', err, e.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (sourceRef.current !== source) return
|
||||||
|
close()
|
||||||
|
if (document.visibilityState === 'hidden') return
|
||||||
|
const delay = backoffRef.current
|
||||||
|
backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS)
|
||||||
|
reconnectTimerRef.current = setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
close()
|
||||||
|
} else if (sourceRef.current === null) {
|
||||||
|
backoffRef.current = BACKOFF_START_MS
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}, [productId])
|
||||||
|
}
|
||||||
706
package-lock.json
generated
706
package-lock.json
generated
|
|
@ -47,6 +47,8 @@
|
||||||
"@mermaid-js/mermaid-cli": "^11.12.0",
|
"@mermaid-js/mermaid-cli": "^11.12.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
|
|
@ -58,6 +60,7 @@
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
"prisma-erd-generator": "^2.4.2",
|
"prisma-erd-generator": "^2.4.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
@ -66,6 +69,13 @@
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/css-tools": {
|
||||||
|
"version": "4.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||||
|
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
|
@ -93,6 +103,57 @@
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@csstools/css-calc": "^3.2.0",
|
||||||
|
"@csstools/css-color-parser": "^4.1.0",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/generational-cache": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
|
|
@ -584,6 +645,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@chevrotain/cst-dts-gen": {
|
"node_modules/@chevrotain/cst-dts-gen": {
|
||||||
"version": "12.0.0",
|
"version": "12.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||||
|
|
@ -626,6 +700,146 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
|
@ -1523,6 +1737,24 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
|
|
@ -4024,6 +4256,93 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@testing-library/dom": {
|
||||||
|
"version": "10.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.10.4",
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/aria-query": "^5.0.1",
|
||||||
|
"aria-query": "5.3.0",
|
||||||
|
"dom-accessibility-api": "^0.5.9",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"picocolors": "1.1.1",
|
||||||
|
"pretty-format": "^27.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/jest-dom": {
|
||||||
|
"version": "6.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
|
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@adobe/css-tools": "^4.4.0",
|
||||||
|
"aria-query": "^5.0.0",
|
||||||
|
"css.escape": "^1.5.1",
|
||||||
|
"dom-accessibility-api": "^0.6.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"redent": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/react": {
|
||||||
|
"version": "16.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||||
|
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@testing-library/dom": "^10.0.0",
|
||||||
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||||
"version": "0.23.0",
|
"version": "0.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||||
|
|
@ -4118,6 +4437,14 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/aria-query": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/bcryptjs": {
|
"node_modules/@types/bcryptjs": {
|
||||||
"version": "2.4.6",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
|
@ -6266,6 +6593,16 @@
|
||||||
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==",
|
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|
@ -7390,6 +7727,27 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css.escape": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -7988,6 +8346,20 @@
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
|
|
@ -8076,6 +8448,13 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
|
|
@ -8349,6 +8728,14 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-accessibility-api": {
|
||||||
|
"version": "0.5.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||||
|
|
@ -8474,6 +8861,19 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||||
|
|
@ -10300,6 +10700,19 @@
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|
@ -10497,6 +10910,16 @@
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/indent-string": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
|
@ -11002,6 +11425,13 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
|
@ -11346,6 +11776,57 @@
|
||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.25.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/lru-cache": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|
@ -12158,6 +12639,17 @@
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lz-string": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"lz-string": "bin/bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -12523,6 +13015,13 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
|
@ -13224,6 +13723,16 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/min-indent": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
|
@ -14114,6 +14623,19 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -14684,6 +15206,55 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "27.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1",
|
||||||
|
"ansi-styles": "^5.0.0",
|
||||||
|
"react-is": "^17.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/react-is": {
|
||||||
|
"version": "17.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||||
|
|
@ -15268,6 +15839,20 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redent": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"indent-string": "^4.0.0",
|
||||||
|
"strip-indent": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -15733,6 +16318,19 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|
@ -16593,6 +17191,19 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-indent": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"min-indent": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
|
@ -16713,6 +17324,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||||
|
|
@ -16988,6 +17606,19 @@
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tree-kill": {
|
"node_modules/tree-kill": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
|
@ -17355,6 +17986,16 @@
|
||||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
@ -17952,6 +18593,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|
@ -17961,6 +18615,41 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -18202,6 +18891,23 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@
|
||||||
"@mermaid-js/mermaid-cli": "^11.12.0",
|
"@mermaid-js/mermaid-cli": "^11.12.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
|
|
@ -73,6 +75,7 @@
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
"prisma-erd-generator": "^2.4.2",
|
"prisma-erd-generator": "^2.4.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
||||||
139
stores/backlog-store.ts
Normal file
139
stores/backlog-store.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import type { PbiStatusApi } from '@/lib/task-status'
|
||||||
|
|
||||||
|
export interface BacklogPbi {
|
||||||
|
id: string
|
||||||
|
code: string | null
|
||||||
|
title: string
|
||||||
|
priority: number
|
||||||
|
description?: string | null
|
||||||
|
created_at: Date
|
||||||
|
status: PbiStatusApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacklogStory {
|
||||||
|
id: string
|
||||||
|
code: string | null
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
acceptance_criteria: string | null
|
||||||
|
priority: number
|
||||||
|
status: string
|
||||||
|
pbi_id: string
|
||||||
|
created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacklogTask {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
priority: number
|
||||||
|
status: string
|
||||||
|
sort_order: number
|
||||||
|
story_id: string
|
||||||
|
created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entity = 'pbi' | 'story' | 'task'
|
||||||
|
type Op = 'I' | 'U' | 'D'
|
||||||
|
|
||||||
|
interface InitialData {
|
||||||
|
pbis: BacklogPbi[]
|
||||||
|
storiesByPbi: Record<string, BacklogStory[]>
|
||||||
|
tasksByStory: Record<string, BacklogTask[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacklogStore extends InitialData {
|
||||||
|
setInitialData: (data: InitialData) => void
|
||||||
|
applyChange: (entity: Entity, op: Op, data: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBacklogStore = create<BacklogStore>((set) => ({
|
||||||
|
pbis: [],
|
||||||
|
storiesByPbi: {},
|
||||||
|
tasksByStory: {},
|
||||||
|
|
||||||
|
setInitialData: (data) => set(data),
|
||||||
|
|
||||||
|
applyChange: (entity, op, data) =>
|
||||||
|
set((state) => {
|
||||||
|
if (entity === 'pbi') {
|
||||||
|
const id = data.id as string
|
||||||
|
if (op === 'D') {
|
||||||
|
return { pbis: state.pbis.filter((p) => p.id !== id) }
|
||||||
|
}
|
||||||
|
if (op === 'U') {
|
||||||
|
return {
|
||||||
|
pbis: state.pbis.map((p) =>
|
||||||
|
p.id === id ? { ...p, ...(data as Partial<BacklogPbi>) } : p
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// I
|
||||||
|
return { pbis: [...state.pbis, data as unknown as BacklogPbi] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity === 'story') {
|
||||||
|
const id = data.id as string
|
||||||
|
if (op === 'D') {
|
||||||
|
const storiesByPbi = { ...state.storiesByPbi }
|
||||||
|
for (const pbiId of Object.keys(storiesByPbi)) {
|
||||||
|
storiesByPbi[pbiId] = storiesByPbi[pbiId].filter((s) => s.id !== id)
|
||||||
|
}
|
||||||
|
return { storiesByPbi }
|
||||||
|
}
|
||||||
|
if (op === 'U') {
|
||||||
|
const storiesByPbi = { ...state.storiesByPbi }
|
||||||
|
for (const pbiId of Object.keys(storiesByPbi)) {
|
||||||
|
const idx = storiesByPbi[pbiId].findIndex((s) => s.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
storiesByPbi[pbiId] = storiesByPbi[pbiId].map((s) =>
|
||||||
|
s.id === id ? { ...s, ...(data as Partial<BacklogStory>) } : s
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { storiesByPbi }
|
||||||
|
}
|
||||||
|
// I
|
||||||
|
const pbiId = data.pbi_id as string
|
||||||
|
return {
|
||||||
|
storiesByPbi: {
|
||||||
|
...state.storiesByPbi,
|
||||||
|
[pbiId]: [...(state.storiesByPbi[pbiId] ?? []), data as unknown as BacklogStory],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// task
|
||||||
|
const id = data.id as string
|
||||||
|
if (op === 'D') {
|
||||||
|
const tasksByStory = { ...state.tasksByStory }
|
||||||
|
for (const storyId of Object.keys(tasksByStory)) {
|
||||||
|
tasksByStory[storyId] = tasksByStory[storyId].filter((t) => t.id !== id)
|
||||||
|
}
|
||||||
|
return { tasksByStory }
|
||||||
|
}
|
||||||
|
if (op === 'U') {
|
||||||
|
const tasksByStory = { ...state.tasksByStory }
|
||||||
|
for (const storyId of Object.keys(tasksByStory)) {
|
||||||
|
const idx = tasksByStory[storyId].findIndex((t) => t.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
tasksByStory[storyId] = tasksByStory[storyId].map((t) =>
|
||||||
|
t.id === id ? { ...t, ...(data as Partial<BacklogTask>) } : t
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tasksByStory }
|
||||||
|
}
|
||||||
|
// I
|
||||||
|
const storyId = data.story_id as string
|
||||||
|
return {
|
||||||
|
tasksByStory: {
|
||||||
|
...state.tasksByStory,
|
||||||
|
[storyId]: [...(state.tasksByStory[storyId] ?? []), data as unknown as BacklogTask],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue