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>
This commit is contained in:
parent
bf7162a5fc
commit
84b2c10c71
2 changed files with 131 additions and 0 deletions
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setActiveSprintAction } from '@/actions/active-sprint'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import type { SprintStatusApi } from '@/lib/task-status'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export function SprintSwitcher({
|
|||
const [isPending, startTransition] = useTransition()
|
||||
const [showClosed, setShowClosed] = useState(false)
|
||||
const buildingSet = new Set(buildingSprintIds)
|
||||
const isDemo = useUserSettingsStore(s => s.context.isDemo)
|
||||
|
||||
const visibleSprints = sprints.filter(s => {
|
||||
if (showClosed) return true
|
||||
|
|
@ -53,6 +55,10 @@ export function SprintSwitcher({
|
|||
|
||||
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)
|
||||
if (result?.error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue