Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/components/layout/MainLayout.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Settings } from 'lucide-react';

vi.mock('../../hooks/useLanguage', () => ({
useLanguage: () => ({ t: (key: string) => key }),
}));

vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: { id: '1', name: 'Test', role: { key: 'admin' } },
logout: vi.fn(),
}),
}));

vi.mock('@/contexts/PermissionsContext', () => ({
usePermissions: () => ({ can: () => true, canAny: () => true, canAll: () => true }),
}));

vi.mock('@/hooks/useDashboardApps', () => ({
useDashboardApps: () => ({ apps: [] }),
}));

vi.mock('@/utils/injectDashboardApps', () => ({
injectDashboardAppsIntoMenu: (items: any[]) => items,
}));

vi.mock('./config/menuItems', () => ({
getCustomerMenuItems: () => [],
filterMenuItemsByPermissions: (items: any[]) => items,
}));

vi.mock('./components', () => ({
Header: () => <div data-testid="header" />,
Sidebar: () => <div data-testid="sidebar" />,
}));

vi.mock('@/components/WelcomeTourModal', () => ({
WelcomeTourModal: () => null,
}));

vi.mock('sonner', () => ({
toast: { loading: vi.fn(), success: vi.fn(), error: vi.fn() },
}));

vi.mock('@evoapi/design-system', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button type="button" onClick={onClick} {...props}>{children}</button>
),
Dialog: ({ children }: any) => <>{children}</>,
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <div>{children}</div>,
DialogDescription: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
}));

const mockUseMenuState = vi.hoisted(() => vi.fn());
const mockSetActiveSubmenu = vi.hoisted(() => vi.fn());

vi.mock('@/hooks/useMenuState', () => ({
useMenuState: mockUseMenuState,
}));

const settingsItem = {
id: 'settings',
name: 'Settings',
href: '#',
icon: Settings,
subItems: [],
};

const defaultMenuState = () => ({
activeSubmenu: settingsItem,
activeMenu: null,
setActiveSubmenu: mockSetActiveSubmenu,
setActiveMenu: vi.fn(),
isMenuItemActive: () => false,
isMenuWithSubItemsActive: () => false,
handleMenuClick: vi.fn(),
resetManualFlag: vi.fn(),
});

import MainLayout from './MainLayout';

describe('MainLayout — backdrop dismissal', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.setItem('sidebar-collapsed', 'true');
mockUseMenuState.mockReturnValue(defaultMenuState());
});

function renderLayout() {
return render(
<MemoryRouter>
<MainLayout>
<div data-testid="content">Content</div>
</MainLayout>
</MemoryRouter>,
);
}

it('renders accessible backdrop when activeSubmenu is set and sidebar is collapsed', () => {
renderLayout();
const backdrop = screen.getByRole('button', { name: 'sidebar.closeSubmenu' });
expect(backdrop).toBeInTheDocument();
expect(backdrop).toHaveAttribute('tabIndex', '0');
});

it('calls setActiveSubmenu(null) when backdrop is clicked', () => {
renderLayout();
const backdrop = screen.getByRole('button', { name: 'sidebar.closeSubmenu' });
fireEvent.click(backdrop);
expect(mockSetActiveSubmenu).toHaveBeenCalledWith(null);
});

it('calls setActiveSubmenu(null) when Enter is pressed on backdrop', () => {
renderLayout();
const backdrop = screen.getByRole('button', { name: 'sidebar.closeSubmenu' });
fireEvent.keyDown(backdrop, { key: 'Enter' });
expect(mockSetActiveSubmenu).toHaveBeenCalledWith(null);
});

it('calls setActiveSubmenu(null) when Space is pressed on backdrop', () => {
renderLayout();
const backdrop = screen.getByRole('button', { name: 'sidebar.closeSubmenu' });
fireEvent.keyDown(backdrop, { key: ' ' });
expect(mockSetActiveSubmenu).toHaveBeenCalledWith(null);
});

it('does not render backdrop when sidebar is expanded', () => {
localStorage.setItem('sidebar-collapsed', 'false');
renderLayout();
expect(screen.queryByRole('button', { name: 'sidebar.closeSubmenu' })).not.toBeInTheDocument();
});

it('does not render backdrop when activeSubmenu is null', () => {
mockUseMenuState.mockReturnValue({ ...defaultMenuState(), activeSubmenu: null });
renderLayout();
expect(screen.queryByRole('button', { name: 'sidebar.closeSubmenu' })).not.toBeInTheDocument();
});
});
18 changes: 16 additions & 2 deletions src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ export default function MainLayout({ children }: MainLayoutProps) {
handleMenuClick={menuState.handleMenuClick}
/>

{/* Main Layout Container */}
<div className="flex flex-1 min-h-0 transition-colors duration-150 ease-in-out">
{/* Main Layout Container — `relative` is the positioning anchor for the collapsed sidebar flyout */}
<div className="flex flex-1 min-h-0 relative transition-colors duration-150 ease-in-out">
{/* Sidebar */}
<Sidebar
isCollapsed={isCollapsed}
Expand All @@ -138,6 +138,20 @@ export default function MainLayout({ children }: MainLayoutProps) {
setActiveSubmenu={menuState.setActiveSubmenu}
/>

{/* Backdrop for collapsed sidebar flyout — starts at left-16 so the icon sidebar remains clickable */}
{menuState.activeSubmenu && isCollapsed && (
<div
role="button"
tabIndex={0}
aria-label={t('sidebar.closeSubmenu')}
className="hidden md:block absolute left-16 top-0 bottom-0 right-0 z-40"
onClick={() => menuState.setActiveSubmenu(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') menuState.setActiveSubmenu(null);
}}
/>
)}

{/* Main Content */}
<main className="flex-1 overflow-auto bg-background transition-colors duration-150 ease-in-out">
<div className="h-full">{children}</div>
Expand Down
55 changes: 55 additions & 0 deletions src/components/layout/components/MenuItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Settings } from 'lucide-react';
import MenuItem from './MenuItem';
import type { MenuItem as MenuItemType } from '../config/menuItems';

vi.mock('@evoapi/design-system', () => ({
Tooltip: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children, asChild }: any) => (asChild ? <>{children}</> : <div>{children}</div>),
TooltipContent: ({ children }: any) => <div data-testid="tooltip-content">{children}</div>,
}));

const settingsItem: MenuItemType = {
id: 'settings',
name: 'Settings',
href: '#',
icon: Settings,
subItems: [{ name: 'General', href: '/settings/general', icon: Settings }],
};

function renderMenuItem(props: Partial<Parameters<typeof MenuItem>[0]> = {}) {
return render(
<MemoryRouter>
<MenuItem
item={settingsItem}
isCollapsed={true}
isActive={false}
activeMenu={null}
onClick={vi.fn()}
{...props}
/>
</MemoryRouter>,
);
}

describe('MenuItem — collapsed tooltip (regression: EVO-1048 iter 1 HIGH fix)', () => {
it('shows only item name in tooltip when collapsed', () => {
renderMenuItem({ isCollapsed: true });
const tooltip = screen.getByTestId('tooltip-content');
expect(tooltip.textContent).toBe('Settings');
});

it('tooltip content contains no interactive links in collapsed mode', () => {
renderMenuItem({ isCollapsed: true });
const tooltip = screen.getByTestId('tooltip-content');
expect(tooltip.querySelector('a')).toBeNull();
expect(tooltip.querySelector('button')).toBeNull();
});

it('does not render tooltip content when sidebar is expanded', () => {
renderMenuItem({ isCollapsed: false });
expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument();
});
});
25 changes: 3 additions & 22 deletions src/components/layout/components/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import {
TooltipTrigger,
} from '@evoapi/design-system';
import { MenuItem as MenuItemType } from '../config/menuItems';

// Utility function for className merging
function cn(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(' ');
}
import { cn } from '@/utils/cn';

interface MenuItemProps {
item: MenuItemType;
Expand Down Expand Up @@ -66,24 +62,9 @@ export default function MenuItem({
if (!mobile && isCollapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
{menuItem}
</TooltipTrigger>
<TooltipTrigger asChild>{menuItem}</TooltipTrigger>
<TooltipContent side="right">
<div>
<p className="font-medium">{item.name}</p>
{hasSubItems && (
<div className="mt-1 space-y-1">
{item.subItems?.map(subItem => (
<div key={subItem.href} className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">
{subItem.name}
</p>
</div>
))}
</div>
)}
</div>
<p className="font-medium">{item.name}</p>
</TooltipContent>
</Tooltip>
);
Expand Down
Loading