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>
This commit is contained in:
parent
84b2c10c71
commit
8086963c6a
2 changed files with 156 additions and 8 deletions
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -49,7 +49,19 @@ export function NavBar({
|
|||
const router = useRouter()
|
||||
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) {
|
||||
if (productId === displayActive?.id) return
|
||||
if (isDemo) {
|
||||
router.push(`/products/${productId}`)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await setActiveProductAction(productId)
|
||||
if (result?.error) {
|
||||
|
|
@ -62,8 +74,6 @@ export function NavBar({
|
|||
})
|
||||
}
|
||||
|
||||
const activeId = activeProduct?.id ?? null
|
||||
|
||||
// Nav link helpers
|
||||
const disabledSpan = (label: string) => (
|
||||
<span
|
||||
|
|
@ -153,7 +163,7 @@ export function NavBar({
|
|||
|
||||
{/* Midden: actief product */}
|
||||
<div className="flex items-center justify-center">
|
||||
{activeProduct ? (
|
||||
{displayActive ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
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"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{activeProduct.name.length > 22
|
||||
? activeProduct.name.slice(0, 22) + '…'
|
||||
: activeProduct.name}
|
||||
{displayActive.name.length > 22
|
||||
? displayActive.name.slice(0, 22) + '…'
|
||||
: displayActive.name}
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -171,9 +181,9 @@ export function NavBar({
|
|||
{products.map(p => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => p.id !== activeProduct.id && handleSwitchProduct(p.id)}
|
||||
onClick={() => p.id !== displayActive.id && handleSwitchProduct(p.id)}
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue