diff --git a/__tests__/components/shared/nav-bar.test.tsx b/__tests__/components/shared/nav-bar.test.tsx new file mode 100644 index 0000000..67fabce --- /dev/null +++ b/__tests__/components/shared/nav-bar.test.tsx @@ -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 & { + children?: React.ReactNode + onClick?: () => void + } + const PassThrough = ({ children }: Props) => <>{children} + const Forwarding = ({ children, ...rest }: Props) =>
{children}
+ return { + DropdownMenu: PassThrough, + DropdownMenuTrigger: Forwarding, + DropdownMenuContent: PassThrough, + DropdownMenuItem: ({ children, onClick, className }: Props) => ( + + ), + 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 +const toastSuccess = toast.success as unknown as ReturnType + +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( + , + ) +} + +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') + }) +}) diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index d05a27f..7b272c1 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -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) => ( - {activeProduct ? ( + {displayActive ? ( - {activeProduct.name.length > 22 - ? activeProduct.name.slice(0, 22) + '…' - : activeProduct.name} + {displayActive.name.length > 22 + ? displayActive.name.slice(0, 22) + '…' + : displayActive.name} @@ -171,9 +181,9 @@ export function NavBar({ {products.map(p => ( 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' )} > {p.name}