The profile pill in the top nav was a static `<div>` showing the
avatar + name + role with no way to reach the dashboard, profile
or logout from the desktop layout — testers reported "không có
dropdown dashboard" after login.
Changes to `components/design-system/navbar.tsx`:
* The pill is now a `<button>` that toggles an absolutely-positioned
menu (right-aligned, `z-popover`, elevation-3 shadow). A chevron
rotates to indicate state.
* Outside-click and Escape close the menu (effect listens only while
the menu is open).
* The menu has:
- A header card with the bigger avatar + full name + email/phone.
- Dashboard / Admin entry (icon depends on role) — replaces the
separate green dashboard button that used to live to the right
of the pill.
- Profile entry → `profileHref`.
- Divider, then a destructive "Đăng xuất" button calling `onLogout`.
* Each link uses the existing `renderLink` slot so framework-specific
Link components (Next.js / next-intl) keep working, and they close
the menu on click.
Tests updated: the dashboard / admin assertions now click the
trigger to open the menu, then look for `role="menuitem"` entries.
All 16 navbar tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
/* eslint-disable import-x/order */
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import * as React from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Mock lucide-react icons to avoid SVG rendering issues
|
|
vi.mock('lucide-react', () => ({
|
|
ChevronDown: () => <span data-testid="icon-chevron-down" />,
|
|
LayoutDashboard: () => <span data-testid="icon-layout-dashboard" />,
|
|
LogOut: () => <span data-testid="icon-logout" />,
|
|
Menu: () => <span data-testid="icon-menu" />,
|
|
Moon: () => <span data-testid="icon-moon" />,
|
|
Shield: () => <span data-testid="icon-shield" />,
|
|
Sun: () => <span data-testid="icon-sun" />,
|
|
User: () => <span data-testid="icon-user" />,
|
|
X: () => <span data-testid="icon-x" />,
|
|
}));
|
|
|
|
import { Navbar, type NavbarProps } from '../navbar';
|
|
|
|
const renderLink: NavbarProps['renderLink'] = ({ href, children, className, onClick }) => (
|
|
<a href={href} className={className} onClick={onClick}>
|
|
{children}
|
|
</a>
|
|
);
|
|
|
|
const baseLabels: NavbarProps['labels'] = {
|
|
login: 'Đăng nhập',
|
|
register: 'Đăng ký',
|
|
dashboard: 'Quản lý',
|
|
admin: 'Quản trị',
|
|
profile: 'Hồ sơ',
|
|
logout: 'Đăng xuất',
|
|
openMenu: 'Mở menu',
|
|
closeMenu: 'Đóng menu',
|
|
darkMode: 'Chế độ tối',
|
|
lightMode: 'Chế độ sáng',
|
|
mainNav: 'Điều hướng chính',
|
|
};
|
|
|
|
const baseLinks: NavbarProps['links'] = [
|
|
{ href: '/', label: 'Trang chủ', isActive: true },
|
|
{ href: '/search', label: 'Tìm kiếm', isActive: false },
|
|
{ href: '/pricing', label: 'Bảng giá', isActive: false },
|
|
];
|
|
|
|
const defaultProps: NavbarProps = {
|
|
brand: 'GoodGo',
|
|
links: baseLinks,
|
|
user: null,
|
|
dashboardHref: '/dashboard',
|
|
theme: 'light',
|
|
onToggleTheme: vi.fn(),
|
|
onLogout: vi.fn(),
|
|
labels: baseLabels,
|
|
renderLink,
|
|
};
|
|
|
|
describe('Navbar', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('renders the brand name', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
expect(screen.getByText('GoodGo')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders as a banner landmark', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
expect(screen.getByRole('banner')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders desktop nav links', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
expect(screen.getAllByText('Trang chủ').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Tìm kiếm').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Bảng giá').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('renders login and register buttons when unauthenticated', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
expect(screen.getAllByText('Đăng nhập').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Đăng ký').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does not render dashboard button when unauthenticated', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
expect(screen.queryByText('Quản lý')).toBeNull();
|
|
});
|
|
|
|
it('renders user full name when authenticated', () => {
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER', email: 'a@test.com' }}
|
|
/>,
|
|
);
|
|
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('renders dashboard menu item for authenticated user (after opening dropdown)', () => {
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
|
|
/>,
|
|
);
|
|
// The pill is the dropdown trigger; click it to reveal the menu.
|
|
const trigger = screen.getByRole('button', { name: /Nguyễn Văn A/ });
|
|
fireEvent.click(trigger);
|
|
expect(screen.getByRole('menuitem', { name: /Quản lý/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders admin label as a role badge AND in the dropdown for ADMIN role', () => {
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
user={{ fullName: 'Admin User', role: 'ADMIN' }}
|
|
/>,
|
|
);
|
|
// Role badge in the trigger pill is always visible.
|
|
expect(screen.getByText('Quản trị viên')).toBeInTheDocument();
|
|
// After opening, the ADMIN-specific menu item shows.
|
|
fireEvent.click(screen.getByRole('button', { name: /Admin User/ }));
|
|
expect(screen.getByRole('menuitem', { name: /Quản trị/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows moon icon in light theme', () => {
|
|
render(<Navbar {...defaultProps} theme="light" />);
|
|
expect(screen.getByTestId('icon-moon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows sun icon in dark theme', () => {
|
|
render(<Navbar {...defaultProps} theme="dark" />);
|
|
expect(screen.getByTestId('icon-sun')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onToggleTheme when theme button is clicked', () => {
|
|
const onToggleTheme = vi.fn();
|
|
render(<Navbar {...defaultProps} onToggleTheme={onToggleTheme} />);
|
|
const themeBtn = screen.getByRole('button', { name: 'Chế độ tối' });
|
|
fireEvent.click(themeBtn);
|
|
expect(onToggleTheme).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('toggles mobile menu on hamburger click', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
const hamburger = screen.getByRole('button', { name: 'Mở menu' });
|
|
fireEvent.click(hamburger);
|
|
expect(screen.getByRole('button', { name: 'Đóng menu' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders main nav accessible label', () => {
|
|
render(<Navbar {...defaultProps} />);
|
|
const navEls = screen.getAllByRole('navigation');
|
|
const mainNavs = navEls.filter((el) => el.getAttribute('aria-label') === 'Điều hướng chính');
|
|
expect(mainNavs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('renders notification slot when provided', () => {
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
user={{ fullName: 'User', role: 'BUYER' }}
|
|
notifications={<button aria-label="Thông báo">🔔</button>}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole('button', { name: 'Thông báo' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders language switcher slot when provided', () => {
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
languageSwitcher={<div data-testid="lang-sw">VI</div>}
|
|
/>,
|
|
);
|
|
expect(screen.getByTestId('lang-sw')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onLogout and closes mobile menu when logout clicked', async () => {
|
|
const onLogout = vi.fn().mockResolvedValue(undefined);
|
|
render(
|
|
<Navbar
|
|
{...defaultProps}
|
|
user={{ fullName: 'User', role: 'BUYER' }}
|
|
onLogout={onLogout}
|
|
/>,
|
|
);
|
|
// Open mobile menu
|
|
fireEvent.click(screen.getByRole('button', { name: 'Mở menu' }));
|
|
const logoutBtn = screen.getByRole('button', { name: 'Đăng xuất' });
|
|
fireEvent.click(logoutBtn);
|
|
expect(onLogout).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|