feat(PBI-80): demo-user mag eigen UI-voorkeuren wijzigen (#194)
* feat(PBI-80): SprintSwitcher demo-fork (ST-1345) Demo-sessies navigeren bij sprint-wissel direct via router.push, zonder de geblokkeerde setActiveSprintAction aan te roepen. De server-action behoudt zijn 403-guard als defense in depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-80): NavBar demo-fork + URL-derived actief product (ST-1346) Demo: product-switch in de NavBar navigeert direct via router.push zonder setActiveProductAction. Voor de weergave (label + dropdown-highlight + nav-links) leiden we voor demo de actieve product af uit pathname, zodat de UI consistent is met de URL — de server-render houdt de seed-default prop maar die wordt voor demo overschreven. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(PBI-80): ADR-0006 addendum + demo-client-state patroon (ST-1347) ADR-0006 krijgt een "Updated 2026-05-12"-sectie die de PBI-80-uitzondering documenteert: client-side UI-prefs (filters, sort, layout, scope-keuze) zijn voor demo toegestaan via in-memory store, terwijl alle data-mutaties three-layer beschermd blijven. Patroon-doc beschrijft wanneer en hoe `isDemo` te gebruiken in nieuwe componenten. CLAUDE.md quickref + docs/INDEX.md ge-update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2bef1a4c20
commit
2b4b5bf719
9 changed files with 667 additions and 8 deletions
|
|
@ -94,6 +94,7 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
|
||||||
| Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` |
|
| Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` |
|
||||||
| Debug-id op component-root | `docs/patterns/debug-id.md` |
|
| Debug-id op component-root | `docs/patterns/debug-id.md` |
|
||||||
| Debug-labels (BEM) | `docs/patterns/debug-labels.md` |
|
| Debug-labels (BEM) | `docs/patterns/debug-labels.md` |
|
||||||
|
| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
138
__tests__/components/shared/nav-bar.test.tsx
Normal file
138
__tests__/components/shared/nav-bar.test.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const pushMock = vi.fn()
|
||||||
|
const refreshMock = vi.fn()
|
||||||
|
const pathnameMock = vi.fn(() => '/dashboard')
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
|
||||||
|
usePathname: () => pathnameMock(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/actions/active-product', () => ({
|
||||||
|
setActiveProductAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/dropdown-menu', () => {
|
||||||
|
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
||||||
|
const Forwarding = ({ children, ...rest }: Props) => <div {...rest}>{children}</div>
|
||||||
|
return {
|
||||||
|
DropdownMenu: PassThrough,
|
||||||
|
DropdownMenuTrigger: Forwarding,
|
||||||
|
DropdownMenuContent: PassThrough,
|
||||||
|
DropdownMenuItem: ({ children, onClick, className }: Props) => (
|
||||||
|
<button type="button" onClick={onClick} className={className} data-testid="dd-item">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tooltip', () => {
|
||||||
|
type Props = { children?: React.ReactNode }
|
||||||
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
||||||
|
return {
|
||||||
|
Tooltip: PassThrough,
|
||||||
|
TooltipContent: PassThrough,
|
||||||
|
TooltipProvider: PassThrough,
|
||||||
|
TooltipTrigger: PassThrough,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null }))
|
||||||
|
vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null }))
|
||||||
|
vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null }))
|
||||||
|
vi.mock('@/components/solo/nav-status-indicators', () => ({
|
||||||
|
SoloNavStatusIndicators: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { NavBar } from '@/components/shared/nav-bar'
|
||||||
|
|
||||||
|
const actionMock = setActiveProductAction as unknown as ReturnType<typeof vi.fn>
|
||||||
|
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{ id: 'A', name: 'Alpha' },
|
||||||
|
{ id: 'B', name: 'Beta' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) {
|
||||||
|
const isDemo = overrides.isDemo ?? false
|
||||||
|
const activeId = overrides.activeProductId ?? 'A'
|
||||||
|
const activeProduct = products.find(p => p.id === activeId) ?? null
|
||||||
|
return render(
|
||||||
|
<NavBar
|
||||||
|
isDemo={isDemo}
|
||||||
|
roles={[]}
|
||||||
|
userId="u1"
|
||||||
|
username="user"
|
||||||
|
email={null}
|
||||||
|
activeProduct={activeProduct}
|
||||||
|
products={products}
|
||||||
|
hasActiveSprint={false}
|
||||||
|
minQuotaPct={100}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
actionMock.mockResolvedValue({ success: true })
|
||||||
|
pathnameMock.mockReturnValue('/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NavBar — product switch', () => {
|
||||||
|
it('demo: clicking another product navigates via router.push without calling the action', () => {
|
||||||
|
renderNavBar({ isDemo: true, activeProductId: 'A' })
|
||||||
|
fireEvent.click(screen.getByText('Beta'))
|
||||||
|
expect(pushMock).toHaveBeenCalledWith('/products/B')
|
||||||
|
expect(actionMock).not.toHaveBeenCalled()
|
||||||
|
expect(toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-demo: clicking another product calls setActiveProductAction', async () => {
|
||||||
|
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
||||||
|
fireEvent.click(screen.getByText('Beta'))
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(actionMock).toHaveBeenCalledWith('B')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NavBar — URL-derived active product (demo only)', () => {
|
||||||
|
it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => {
|
||||||
|
pathnameMock.mockReturnValue('/products/B/sprint')
|
||||||
|
const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' })
|
||||||
|
const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]')
|
||||||
|
expect(trigger?.textContent).toContain('Beta')
|
||||||
|
expect(trigger?.textContent).not.toContain('Alpha')
|
||||||
|
const items = screen.getAllByTestId('dd-item')
|
||||||
|
const itemB = items.find(el => el.textContent?.includes('Beta'))
|
||||||
|
expect(itemB?.className).toContain('bg-primary-container')
|
||||||
|
const itemA = items.find(el => el.textContent?.includes('Alpha'))
|
||||||
|
expect(itemA?.className ?? '').not.toContain('bg-primary-container')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-demo: pathname does NOT override the activeProduct prop', () => {
|
||||||
|
pathnameMock.mockReturnValue('/products/B/sprint')
|
||||||
|
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
||||||
|
// Label still reflects server-rendered activeProduct (Alpha)
|
||||||
|
const items = screen.getAllByTestId('dd-item')
|
||||||
|
const itemA = items.find(el => el.textContent?.includes('Alpha'))
|
||||||
|
expect(itemA?.className).toContain('bg-primary-container')
|
||||||
|
})
|
||||||
|
})
|
||||||
125
__tests__/components/shared/sprint-switcher.test.tsx
Normal file
125
__tests__/components/shared/sprint-switcher.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const pushMock = vi.fn()
|
||||||
|
const refreshMock = vi.fn()
|
||||||
|
const pathnameMock = vi.fn(() => '/products/p1/sprint')
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
|
||||||
|
usePathname: () => pathnameMock(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/actions/active-sprint', () => ({
|
||||||
|
setActiveSprintAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isDemoMock = { value: false }
|
||||||
|
vi.mock('@/stores/user-settings/store', () => ({
|
||||||
|
useUserSettingsStore: (selector: (s: { context: { isDemo: boolean } }) => unknown) =>
|
||||||
|
selector({ context: { isDemo: isDemoMock.value } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/dropdown-menu', () => {
|
||||||
|
type Props = { children?: React.ReactNode; onClick?: () => void; className?: string }
|
||||||
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
||||||
|
return {
|
||||||
|
DropdownMenu: PassThrough,
|
||||||
|
DropdownMenuTrigger: PassThrough,
|
||||||
|
DropdownMenuContent: PassThrough,
|
||||||
|
DropdownMenuItem: ({ children, onClick, className }: Props) => (
|
||||||
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tooltip', () => {
|
||||||
|
type Props = { children?: React.ReactNode }
|
||||||
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
||||||
|
return {
|
||||||
|
Tooltip: PassThrough,
|
||||||
|
TooltipContent: PassThrough,
|
||||||
|
TooltipProvider: PassThrough,
|
||||||
|
TooltipTrigger: PassThrough,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { setActiveSprintAction } from '@/actions/active-sprint'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||||
|
|
||||||
|
const actionMock = setActiveSprintAction as unknown as ReturnType<typeof vi.fn>
|
||||||
|
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
||||||
|
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const sprints = [
|
||||||
|
{ id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const },
|
||||||
|
{ id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
isDemoMock.value = false
|
||||||
|
actionMock.mockResolvedValue({ success: true })
|
||||||
|
pathnameMock.mockReturnValue('/products/p1/sprint')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SprintSwitcher', () => {
|
||||||
|
it('demo: clicking another sprint navigates via router.push without calling the action', () => {
|
||||||
|
isDemoMock.value = true
|
||||||
|
render(
|
||||||
|
<SprintSwitcher
|
||||||
|
productId="p1"
|
||||||
|
sprints={sprints}
|
||||||
|
activeSprint={sprints[0]}
|
||||||
|
buildingSprintIds={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Goal 2'))
|
||||||
|
expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2')
|
||||||
|
expect(actionMock).not.toHaveBeenCalled()
|
||||||
|
expect(toastError).not.toHaveBeenCalled()
|
||||||
|
expect(toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-demo: clicking another sprint calls setActiveSprintAction', async () => {
|
||||||
|
isDemoMock.value = false
|
||||||
|
render(
|
||||||
|
<SprintSwitcher
|
||||||
|
productId="p1"
|
||||||
|
sprints={sprints}
|
||||||
|
activeSprint={sprints[0]}
|
||||||
|
buildingSprintIds={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Goal 2'))
|
||||||
|
// Wait microtask for the transition to flush.
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(actionMock).toHaveBeenCalledWith('p1', 's2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking the already-active sprint does nothing', () => {
|
||||||
|
isDemoMock.value = true
|
||||||
|
render(
|
||||||
|
<SprintSwitcher
|
||||||
|
productId="p1"
|
||||||
|
sprints={sprints}
|
||||||
|
activeSprint={sprints[0]}
|
||||||
|
buildingSprintIds={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Goal 1'))
|
||||||
|
expect(pushMock).not.toHaveBeenCalled()
|
||||||
|
expect(actionMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -49,7 +49,19 @@ export function NavBar({
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null
|
||||||
|
const displayActive =
|
||||||
|
isDemo && urlProductId
|
||||||
|
? products.find(p => p.id === urlProductId) ?? activeProduct
|
||||||
|
: activeProduct
|
||||||
|
const activeId = displayActive?.id ?? null
|
||||||
|
|
||||||
function handleSwitchProduct(productId: string) {
|
function handleSwitchProduct(productId: string) {
|
||||||
|
if (productId === displayActive?.id) return
|
||||||
|
if (isDemo) {
|
||||||
|
router.push(`/products/${productId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await setActiveProductAction(productId)
|
const result = await setActiveProductAction(productId)
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
|
|
@ -62,8 +74,6 @@ export function NavBar({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = activeProduct?.id ?? null
|
|
||||||
|
|
||||||
// Nav link helpers
|
// Nav link helpers
|
||||||
const disabledSpan = (label: string) => (
|
const disabledSpan = (label: string) => (
|
||||||
<span
|
<span
|
||||||
|
|
@ -153,7 +163,7 @@ export function NavBar({
|
||||||
|
|
||||||
{/* Midden: actief product */}
|
{/* Midden: actief product */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{activeProduct ? (
|
{displayActive ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
|
@ -161,9 +171,9 @@ export function NavBar({
|
||||||
className="flex items-center gap-1 text-sm font-medium text-foreground hover:text-primary transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
|
className="flex items-center gap-1 text-sm font-medium text-foreground hover:text-primary transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[180px]">
|
<span className="truncate max-w-[180px]">
|
||||||
{activeProduct.name.length > 22
|
{displayActive.name.length > 22
|
||||||
? activeProduct.name.slice(0, 22) + '…'
|
? displayActive.name.slice(0, 22) + '…'
|
||||||
: activeProduct.name}
|
: displayActive.name}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -171,9 +181,9 @@ export function NavBar({
|
||||||
{products.map(p => (
|
{products.map(p => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => p.id !== activeProduct.id && handleSwitchProduct(p.id)}
|
onClick={() => p.id !== displayActive.id && handleSwitchProduct(p.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
p.id === activeProduct.id && 'bg-primary-container text-primary-container-foreground font-medium'
|
p.id === displayActive.id && 'bg-primary-container text-primary-container-foreground font-medium'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{p.name}</span>
|
<span className="truncate">{p.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function SprintSwitcher({
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [showClosed, setShowClosed] = useState(false)
|
const [showClosed, setShowClosed] = useState(false)
|
||||||
const buildingSet = new Set(buildingSprintIds)
|
const buildingSet = new Set(buildingSprintIds)
|
||||||
|
const isDemo = useUserSettingsStore(s => s.context.isDemo)
|
||||||
|
|
||||||
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
|
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
|
||||||
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
|
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
|
||||||
|
|
@ -65,6 +66,10 @@ export function SprintSwitcher({
|
||||||
|
|
||||||
function handleSwitchSprint(sprintId: string) {
|
function handleSwitchSprint(sprintId: string) {
|
||||||
if (sprintId === activeSprint?.id) return
|
if (sprintId === activeSprint?.id) return
|
||||||
|
if (isDemo) {
|
||||||
|
router.push(`/products/${productId}/sprint/${sprintId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await switchActiveSprintAction(productId, sprintId)
|
const result = await switchActiveSprintAction(productId, sprintId)
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ Auto-generated on 2026-05-11 from front-matter and headings.
|
||||||
| [Bidirectionele async-comms MCP-agent ↔ user](./patterns/claude-question-channel.md) | active | 2026-05-03 |
|
| [Bidirectionele async-comms MCP-agent ↔ user](./patterns/claude-question-channel.md) | active | 2026-05-03 |
|
||||||
| [Debug-id op component-root](./patterns/debug-id.md) | active | 2026-05-09 |
|
| [Debug-id op component-root](./patterns/debug-id.md) | active | 2026-05-09 |
|
||||||
| [Debug-labels: BEM data-debug-id patroon](./patterns/debug-labels.md) | active | 2026-05-09 |
|
| [Debug-labels: BEM data-debug-id patroon](./patterns/debug-labels.md) | active | 2026-05-09 |
|
||||||
|
| [Demo client-state (UI-prefs zonder DB)](./patterns/demo-client-state.md) | active | 2026-05-12 |
|
||||||
| [Entity Dialog](./patterns/dialog.md) | active | 2026-05-08 |
|
| [Entity Dialog](./patterns/dialog.md) | active | 2026-05-08 |
|
||||||
| [iron-session](./patterns/iron-session.md) | active | 2026-05-03 |
|
| [iron-session](./patterns/iron-session.md) | active | 2026-05-03 |
|
||||||
| [Prisma Client singleton](./patterns/prisma-client.md) | active | 2026-05-03 |
|
| [Prisma Client singleton](./patterns/prisma-client.md) | active | 2026-05-03 |
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,24 @@ Write protection for the demo user is enforced at **three independent layers**:
|
||||||
|
|
||||||
- Three enforcement sites for every new write operation — easy to miss one when adding a new feature.
|
- Three enforcement sites for every new write operation — easy to miss one when adding a new feature.
|
||||||
- Mitigation: the `DemoTooltip` pattern is documented in `docs/patterns/` and enforced in code review.
|
- Mitigation: the `DemoTooltip` pattern is documented in `docs/patterns/` and enforced in code review.
|
||||||
|
|
||||||
|
## Updated 2026-05-12 — Exception for client-side UI preferences
|
||||||
|
|
||||||
|
PBI-80 relaxes the policy *for client-side UI preferences only*:
|
||||||
|
|
||||||
|
- **Allowed for demo:** product-switch and sprint-switch via URL navigation,
|
||||||
|
filters/sort, layout state (split-panes, collapsed PBIs, selections) —
|
||||||
|
routed through the in-memory `useUserSettingsStore`.
|
||||||
|
- **Why this is safe:** none of these touch the database. The demo user is a
|
||||||
|
single shared row, but each visitor's browser holds its own Zustand store
|
||||||
|
and URL state. A refresh resets to seed defaults; visitors never see each
|
||||||
|
other's choices.
|
||||||
|
- **Unchanged — three-layer enforcement still applies to:** all data mutations
|
||||||
|
(PBI/story/task/sprint create/update/delete/reorder), account fields
|
||||||
|
(username, password, email), role assignment, QR-pairing, web-push, and any
|
||||||
|
cron/webhook secrets.
|
||||||
|
- **Pattern for new demo-friendly features:** if it is UI state, route it
|
||||||
|
through `useUserSettingsStore.setPref` (which already has a demo-fork at
|
||||||
|
[stores/user-settings/store.ts:80](../../stores/user-settings/store.ts)) or
|
||||||
|
pure URL navigation via `router.push`. Never call a server action for demo.
|
||||||
|
See [docs/patterns/demo-client-state.md](../patterns/demo-client-state.md).
|
||||||
|
|
|
||||||
129
docs/patterns/demo-client-state.md
Normal file
129
docs/patterns/demo-client-state.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
---
|
||||||
|
title: "Demo client-state (UI-prefs zonder DB)"
|
||||||
|
status: active
|
||||||
|
audience: [ai-agent, contributor]
|
||||||
|
language: nl
|
||||||
|
last_updated: 2026-05-12
|
||||||
|
when_to_read: "Bij elk nieuw UI-element dat de demo-gebruiker zou willen kunnen wijzigen — filter, sortering, panel-state, geselecteerde scope (product/sprint), enz."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Patroon: Demo client-state
|
||||||
|
|
||||||
|
De demo-gebruiker (`session.isDemo === true`) deelt één DB-rij met alle andere
|
||||||
|
demo-bezoekers. DB-writes voor demo zouden cross-bezoeker-pollution geven, dus
|
||||||
|
de three-layer policy uit [ADR-0006](../adr/0006-demo-user-three-layer-policy.md)
|
||||||
|
blokkeert ze. PBI-80 introduceert één uitzondering: **client-side UI-state mag
|
||||||
|
gewijzigd worden, in-memory en zonder server-call.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wanneer toepassen
|
||||||
|
|
||||||
|
| Soort wijziging | Voor demo? | Hoe |
|
||||||
|
|---|---|---|
|
||||||
|
| Filter / sortering / collapse / split-pane / selectie | **Ja** | `useUserSettingsStore.setPref([...], value)` — store regelt de demo-fork al |
|
||||||
|
| Wisselen van actief product of sprint | **Ja** | `router.push('/products/...')` zonder server-action |
|
||||||
|
| PBI/story/taak/sprint create/update/delete/reorder | **Nee** | Server-action met 403-guard blijft hard verplicht |
|
||||||
|
| Account, rollen, pairing, web-push | **Nee** | Idem |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoe `isDemo` lezen (client component)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
|
||||||
|
const isDemo = useUserSettingsStore(s => s.context.isDemo)
|
||||||
|
```
|
||||||
|
|
||||||
|
`UserSettingsBridge` hydrateert deze waarde in `app/(app)/layout.tsx`,
|
||||||
|
dus elke client child ziet meteen de juiste vlag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voorbeeld 1 — UI pref (filters, sort, layout)
|
||||||
|
|
||||||
|
Geen extra werk. De store-actie regelt de demo-fork zelf:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Werkt voor alle gebruikers, demo + niet-demo
|
||||||
|
useUserSettingsStore.getState().setPref(
|
||||||
|
['views', 'pbiList', 'filterStatus'],
|
||||||
|
'OPEN',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Voor demo doet `setPref` een lokale Zustand-merge zonder server-call;
|
||||||
|
voor niet-demo gaat het via `updateUserSettingsAction` (DB + SSE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voorbeeld 2 — Scope-wissel (product/sprint)
|
||||||
|
|
||||||
|
Fork in de UI-handler — server-action blijft achter de fork onveranderd:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function handleSwitchProduct(productId: string) {
|
||||||
|
if (productId === activeId) return
|
||||||
|
if (isDemo) {
|
||||||
|
router.push(`/products/${productId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setActiveProductAction(productId)
|
||||||
|
// ... bestaande not-demo flow
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Voor pagina's waarvan de scope al in de URL zit (zoals `/products/[id]/sprint/[sprintId]`)
|
||||||
|
is `router.push` met de gewenste path voldoende — server resolveert de
|
||||||
|
juiste data uit de URL-params.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visuele consistentie na URL-only switch
|
||||||
|
|
||||||
|
Server-rendered layouts blijven voor demo de seed-default lezen
|
||||||
|
(`user.active_product_id`, `user.settings.layout.activeSprints[...]`). Als de
|
||||||
|
UI een "actief X"-label toont dat van de server-prop komt, leid het voor demo
|
||||||
|
af uit `pathname`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null
|
||||||
|
const displayActive =
|
||||||
|
isDemo && urlProductId
|
||||||
|
? products.find(p => p.id === urlProductId) ?? activeProduct
|
||||||
|
: activeProduct
|
||||||
|
```
|
||||||
|
|
||||||
|
Gebruik `displayActive` in de render in plaats van de prop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verboden voor demo
|
||||||
|
|
||||||
|
- Server-action aanroepen zonder fork — 403 + onnodige toast.
|
||||||
|
- Wegschrijven naar cookies of localStorage — pollutie tussen bezoekers.
|
||||||
|
- `setActiveSprintInSettings` / vergelijkbare DB-helpers rechtstreeks aanroepen.
|
||||||
|
- Web-push subscription registreren — schrijft naar gedeelde `PushSubscription`-tabel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defense in depth
|
||||||
|
|
||||||
|
Server-actions (`actions/active-product.ts`, `actions/active-sprint.ts`,
|
||||||
|
`actions/user-settings.ts`) **behouden** hun `if (session.isDemo) return 403`-guard.
|
||||||
|
Als toekomstige UI-code per ongeluk de fork mist, faalt de call hard met 403 en
|
||||||
|
zien we het via toast/logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zie ook
|
||||||
|
|
||||||
|
- [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) — three-layer
|
||||||
|
beschermingen + de PBI-80-uitzondering.
|
||||||
|
- [docs/patterns/proxy.md](./proxy.md) — proxy-laag die `/api/*`-writes voor
|
||||||
|
demo afvangt.
|
||||||
|
- [stores/user-settings/store.ts](../../stores/user-settings/store.ts) — bron
|
||||||
|
van waarheid voor `isDemo` + `setPref` met demo-fork.
|
||||||
229
docs/plans/PBI-80-demo-prefs.md
Normal file
229
docs/plans/PBI-80-demo-prefs.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen
|
||||||
|
|
||||||
|
> Stories: ST-1345, ST-1346, ST-1347, ST-1348
|
||||||
|
> Branch: `feat/demo-prefs`
|
||||||
|
> Aangemaakt: 2026-05-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
De demo-gebruiker (`username='demo'`, één gedeelde DB-rij met `is_demo=true`) zit nu
|
||||||
|
vast op het seed-default product en de seed-default sprint. Elke poging om te wisselen
|
||||||
|
of een filter te wijzigen geeft een 403-toast ("Niet beschikbaar in demo-modus"), wat
|
||||||
|
een potentiële klant geen goed beeld geeft van wat de app kan: hij kan niet door
|
||||||
|
producten bladeren, geen alternatieve sprint openen, geen filter ervaren.
|
||||||
|
|
||||||
|
**Beperking.** Alle demo-bezoekers delen één DB-rij. Directe DB-persistentie van
|
||||||
|
demo-prefs zou cross-bezoeker-pollution geven (A's keuze zichtbaar voor B). Schrijven
|
||||||
|
naar `user.settings` voor de demo-rij is dus structureel onveilig.
|
||||||
|
|
||||||
|
**Doel.** Demo mag binnen één browsertab zijn UI-context vrij wijzigen
|
||||||
|
(product, sprint, filters, layout). Geen DB-mutaties — alle wijzigingen sterven aan
|
||||||
|
het einde van de tab/refresh. De huidige three-layer beschermingen voor data-mutaties
|
||||||
|
blijven volledig intact.
|
||||||
|
|
||||||
|
**Bestaande infra die we hergebruiken.**
|
||||||
|
- [stores/user-settings/store.ts:80](../../stores/user-settings/store.ts) — `setPref`
|
||||||
|
heeft al een demo-fork (lokale merge zonder server-call).
|
||||||
|
- [components/shared/user-settings-bridge.tsx:34](../../components/shared/user-settings-bridge.tsx)
|
||||||
|
— skipt SSE en server-sync voor demo.
|
||||||
|
- Filters/sort/layout/selecties lopen volledig via `useUserSettingsStore` — alleen
|
||||||
|
verifiëren dat de UI niets extra's vraagt aan de server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beslissingen
|
||||||
|
|
||||||
|
| Onderwerp | Keuze | Implicatie |
|
||||||
|
|---|---|---|
|
||||||
|
| Persistentie | **In-memory** (Zustand) | Geen cookie, geen localStorage, geen DB. Refresh = reset. |
|
||||||
|
| Scope prefs | Filters/sort + layout (split-panes, collapsed PBIs, selecties) | Debug-mode en notificaties **buiten** scope. |
|
||||||
|
| Documentatie | ADR-0006 update + addendum | One-stop: lezer ziet uitzondering bij oorspronkelijke beslissing. |
|
||||||
|
| Server-actions | **Behouden 403 voor demo** | Defense in depth blijft intact. UI roept ze gewoon niet aan voor demo. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**Demo MAG (nieuw):**
|
||||||
|
- Wisselen van actief product (URL-navigatie + NavBar reflectie)
|
||||||
|
- Wisselen van actieve sprint binnen een product (URL-navigatie)
|
||||||
|
- Filters wijzigen (status, priority) op backlog en sprint-board — *werkt al*
|
||||||
|
- Sortering wijzigen (kolom-headers) — *werkt al*
|
||||||
|
- Collapse/expand van PBIs, selectie van actieve PBI/story/taak — *werkt al*
|
||||||
|
- Split-pane breedte verslepen — *werkt al*
|
||||||
|
|
||||||
|
**Demo MAG NIET (ongewijzigd):**
|
||||||
|
- PBI/story/taak aanmaken, wijzigen, verwijderen, verplaatsen
|
||||||
|
- Sprints openen/sluiten, builden, archiveren
|
||||||
|
- Rollen toekennen of intrekken (`UserRole`)
|
||||||
|
- Accountgegevens wijzigen (username, password, email)
|
||||||
|
- QR-pairing, web-push abonnement, notificaties
|
||||||
|
- Debug-mode toggle
|
||||||
|
- Cron / webhook secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectuur
|
||||||
|
|
||||||
|
**In-memory only.** Client-side `useUserSettingsStore` is de enige bron van
|
||||||
|
demo-state. Server-actions blijven 403 retourneren — die blokkade is geen bug maar
|
||||||
|
een veiligheidsnet voor het geval client-code per ongeluk een server-call doet. De UI
|
||||||
|
moet voor demo de server-call dus *gewoon overslaan*.
|
||||||
|
|
||||||
|
**Product-switch (geen DB-write).**
|
||||||
|
- Vandaag: NavBar roept `setActiveProductAction(productId)` → 403 → toast.
|
||||||
|
- Nieuw: voor demo doet NavBar alleen `router.push('/products/X')`. Geen action.
|
||||||
|
- Server-render van layouts blijft `user.active_product_id` (de seed-default) lezen,
|
||||||
|
maar de NavBar leidt z'n weergegeven actieve product voor demo af uit `pathname`,
|
||||||
|
zodat label en highlight kloppen met waar je daadwerkelijk bent.
|
||||||
|
|
||||||
|
**Sprint-switch (geen DB-write).**
|
||||||
|
- Vandaag: SprintSwitcher roept `setActiveSprintAction` → 403 → toast.
|
||||||
|
- Nieuw: voor demo doet de switcher alleen `router.push('/products/X/sprint/Y')`. De
|
||||||
|
sprint-pagina is `[sprintId]`-driven, dus de juiste sprint laadt zonder dat
|
||||||
|
`user.settings.layout.activeSprints[X]` ge-update hoeft te worden.
|
||||||
|
|
||||||
|
**Filters / layout / selecties.** Ongewijzigd. Te verifiëren dat alle UI-componenten
|
||||||
|
`setPref` gebruiken (niet rechtstreeks een server-action of fetch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stories & taken
|
||||||
|
|
||||||
|
### ST-1345 — SprintSwitcher demo-fork
|
||||||
|
|
||||||
|
| Taak | Bestand | Beschrijving |
|
||||||
|
|---|---|---|
|
||||||
|
| T-950 | [components/shared/sprint-switcher.tsx](../../components/shared/sprint-switcher.tsx) | Lees `isDemo` uit store + fork `handleSwitchSprint` |
|
||||||
|
| T-951 | `__tests__/components/shared/sprint-switcher.test.tsx` | Vitest: demo vs niet-demo gedrag |
|
||||||
|
|
||||||
|
### ST-1346 — NavBar demo-fork + URL-derived display
|
||||||
|
|
||||||
|
| Taak | Bestand | Beschrijving |
|
||||||
|
|---|---|---|
|
||||||
|
| T-952 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | Fork `handleSwitchProduct` voor demo |
|
||||||
|
| T-953 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | URL-derived `displayActive` voor label + highlight |
|
||||||
|
| T-954 | `__tests__/components/shared/nav-bar.test.tsx` | Vitest: handler-fork + URL-derived display |
|
||||||
|
|
||||||
|
### ST-1347 — ADR-0006 update + patroon-doc
|
||||||
|
|
||||||
|
| Taak | Bestand | Beschrijving |
|
||||||
|
|---|---|---|
|
||||||
|
| T-955 | [docs/adr/0006-demo-user-three-layer-policy.md](../adr/0006-demo-user-three-layer-policy.md) | "Updated 2026-05-11"-sectie met uitzondering |
|
||||||
|
| T-956 | `docs/patterns/demo-client-state.md` (optioneel) | Patroon-doc + CLAUDE.md quickref-rij |
|
||||||
|
|
||||||
|
### ST-1348 — Verificatie
|
||||||
|
|
||||||
|
| Taak | Bestand | Beschrijving |
|
||||||
|
|---|---|---|
|
||||||
|
| T-957 | `__tests__/**` | Bestaande tests bijwerken die 403-toast voor demo verwachten |
|
||||||
|
| T-958 | n.v.t. | Browser-flow + DB-no-pollution + defense-in-depth + build |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concrete code-wijzigingen (samengevat)
|
||||||
|
|
||||||
|
### 1. SprintSwitcher fork
|
||||||
|
|
||||||
|
[components/shared/sprint-switcher.tsx:54](../../components/shared/sprint-switcher.tsx):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const isDemo = useUserSettingsStore(s => s.context.isDemo)
|
||||||
|
|
||||||
|
function handleSwitchSprint(sprintId: string) {
|
||||||
|
if (sprintId === activeSprint?.id) return
|
||||||
|
if (isDemo) {
|
||||||
|
router.push(`/products/${productId}/sprint/${sprintId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setActiveSprintAction(productId, sprintId)
|
||||||
|
// ... bestaande logica
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. NavBar fork + URL-derived display
|
||||||
|
|
||||||
|
[components/shared/nav-bar.tsx:48](../../components/shared/nav-bar.tsx):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const pathname = usePathname()
|
||||||
|
const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null
|
||||||
|
const displayActive = isDemo && urlProductId
|
||||||
|
? (products.find(p => p.id === urlProductId) ?? activeProduct)
|
||||||
|
: activeProduct
|
||||||
|
|
||||||
|
function handleSwitchProduct(productId: string) {
|
||||||
|
if (productId === activeProduct?.id) return
|
||||||
|
if (isDemo) {
|
||||||
|
router.push(`/products/${productId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setActiveProductAction(productId)
|
||||||
|
// ... bestaande logica
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In de render: vervang gebruik van `activeProduct` door `displayActive` in label,
|
||||||
|
highlight-class en onClick-equality-check.
|
||||||
|
|
||||||
|
### 3. ADR-0006 addendum
|
||||||
|
|
||||||
|
Nieuwe sectie **"Updated 2026-05-11 — Exception for client-side UI preferences"**
|
||||||
|
na "Consequences" — zie T-955 implementation_plan voor volledige tekst.
|
||||||
|
|
||||||
|
### 4. Server-actions — *geen wijziging*
|
||||||
|
|
||||||
|
Alle 403-guards blijven:
|
||||||
|
- [actions/active-product.ts:20](../../actions/active-product.ts)
|
||||||
|
- [actions/active-sprint.ts:24](../../actions/active-sprint.ts)
|
||||||
|
- [actions/user-settings.ts:28](../../actions/user-settings.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificatie (end-to-end checklist)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run verify && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functioneel — handmatig in browser (zie T-958):**
|
||||||
|
|
||||||
|
1. Reset: `psql $DATABASE_URL -c "UPDATE \"User\" SET settings='{}'::jsonb, active_product_id=NULL WHERE username='demo';"`
|
||||||
|
2. Login als `demo`/`demo1234`.
|
||||||
|
3. `/dashboard` → producten zichtbaar → klik ander product → URL klopt → label klopt → géén toast.
|
||||||
|
4. Backlog → status/priority filter wijzigen → werkt direct → géén POST in Network-tab.
|
||||||
|
5. Sort op kolom → werkt direct.
|
||||||
|
6. Sprint-switcher → andere sprint → URL klopt → board laadt → géén toast.
|
||||||
|
7. Split-pane verslepen → blijft binnen sessie.
|
||||||
|
8. Hard refresh → defaults terug (verwacht in-memory).
|
||||||
|
9. Tweede tab → eigen state, geen kruisbestuiving.
|
||||||
|
|
||||||
|
**Defense in depth:**
|
||||||
|
|
||||||
|
10. DevTools console: `await fetch('/api/products', {method:'POST',body:'{}'})` → 403.
|
||||||
|
11. `grep -rn "session.isDemo" actions/` → alle write-actions houden hun guard.
|
||||||
|
|
||||||
|
**DB-no-pollution:**
|
||||||
|
|
||||||
|
12. `SELECT settings, active_product_id FROM "User" WHERE username='demo';` → `{}` en NULL.
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
|
||||||
|
13. `npm test` → alle tests slagen, inclusief nieuwe NavBar/SprintSwitcher tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risico's & mitigaties
|
||||||
|
|
||||||
|
| Risico | Mitigatie |
|
||||||
|
|---|---|
|
||||||
|
| Toekomstige UI-code roept per ongeluk een write-action aan voor demo | Server-action 403 blijft + nieuwe `demo-client-state.md` patroon-doc + ADR-0006 update |
|
||||||
|
| Server-side render van NavBar toont seed-default `activeProduct` na product-switch | URL-derived `displayActive` (T-953) |
|
||||||
|
| `setActiveSprintInSettings()` in [lib/active-sprint.ts:51](../../lib/active-sprint.ts) heeft geen interne demo-check (huidige tech debt) | Buiten scope: alle bekende callers checken al `session.isDemo`. Eventueel apart op te pakken. |
|
||||||
|
| Demo verliest filterkeuze bij refresh | Acceptabel volgens vragenronde (in-memory gekozen). |
|
||||||
Loading…
Add table
Add a link
Reference in a new issue