feat: add sidebar store and utility functions with tests
- Introduced `useSidebarStore` for managing sidebar state, including opened keys and initialization logic. - Added utility functions `getAllParentKeys`, `shouldShowMenuChildren`, and `getVisibleMenuKeys` to enhance sidebar functionality. - Created unit tests for the sidebar store and utility functions to ensure correct behavior and state management. - Updated `sidebar.tsx` to utilize the new utility functions for improved menu item rendering. These changes enhance the sidebar's functionality and state management, providing a better user experience in navigating the application.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../core/storage/local', () => ({
|
||||
appStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
AppStorageKey: {
|
||||
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
|
||||
},
|
||||
}));
|
||||
|
||||
import { appStorage } from '../../../../core/storage/local';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
|
||||
const allParentKeys = ['sales', 'sales-data', 'sales-activities'];
|
||||
|
||||
describe('useSidebarStore', () => {
|
||||
beforeEach(() => {
|
||||
useSidebarStore.setState({
|
||||
openedKeys: new Set(),
|
||||
isInitialized: false,
|
||||
isAllExpanded: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
vi.mocked(appStorage.getItem).mockReset();
|
||||
vi.mocked(appStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
it('restores saved open keys so nested sales branches can render together', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(['sales', 'sales-data']);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(useSidebarStore.getState().isInitialized).toBe(true);
|
||||
expect(useSidebarStore.getState().isAllExpanded).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the active path on first visit when nothing is saved', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(null);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales', 'sales-data'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(appStorage.setItem).toHaveBeenCalledWith('sidebar_open_menus', ['sales', 'sales-data']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { useTranslation } from '@repo/core-i18n';
|
||||
import { shortcutsData } from '@repo/ui/constants';
|
||||
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
import { useDebouncedValue } from '@repo/ui/hooks';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,7 +75,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
|
||||
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
||||
|
||||
const effectivelyOpened = isSearching || isOpened;
|
||||
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newOpened: boolean) => {
|
||||
@@ -84,6 +85,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
@@ -106,8 +108,12 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasChildren &&
|
||||
item.children!.map((child) => (
|
||||
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
|
||||
{hasChildren ? <></> : undefined}
|
||||
</NavLink>
|
||||
{hasChildren && effectivelyOpened ? (
|
||||
<Box ps="lg">
|
||||
{item.children!.map((child) => (
|
||||
<MenuItemExpanded
|
||||
key={child.key}
|
||||
item={child}
|
||||
@@ -116,7 +122,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -236,17 +244,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
||||
// SidebarMenu Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getAllParentKeys = (items: MenuItemType[]): string[] => {
|
||||
let keys: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys.push(item.key);
|
||||
keys = keys.concat(getAllParentKeys(item.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const SidebarMenu = memo(function SidebarMenu({
|
||||
items,
|
||||
variantOverride,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MENU_ITEMS } from '../data/menu.data';
|
||||
import { getAllParentKeys, getVisibleMenuKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
|
||||
describe('shouldShowMenuChildren', () => {
|
||||
it('shows children when the branch is open or the user is searching', () => {
|
||||
expect(shouldShowMenuChildren(true, false)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, true)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllParentKeys', () => {
|
||||
it('includes nested groups under sales, logistics, and settings', () => {
|
||||
expect(getAllParentKeys(MENU_ITEMS)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'sales',
|
||||
'sales-data',
|
||||
'sales-activities',
|
||||
'logistics',
|
||||
'logistics-data',
|
||||
'settings',
|
||||
'settings-data',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleMenuKeys', () => {
|
||||
it('keeps all siblings of an expanded nested branch visible after restore', () => {
|
||||
const openedKeys = new Set(['sales', 'sales-data']);
|
||||
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, openedKeys);
|
||||
|
||||
expect(keys).toContain('sales-employees');
|
||||
expect(keys).toContain('sales-cycles');
|
||||
expect(keys).toContain('sales-activities');
|
||||
expect(keys).toContain('sales-reports');
|
||||
expect(keys).not.toContain('sales-requests');
|
||||
});
|
||||
|
||||
it('reveals every nested item while searching', () => {
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, new Set(), true);
|
||||
|
||||
expect(keys).toContain('sales-requests');
|
||||
expect(keys).toContain('logistics-packing-slips');
|
||||
expect(keys).toContain('system-users');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MenuItemType } from '../types/menu.types';
|
||||
|
||||
export function getAllParentKeys(items: MenuItemType[]): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys.push(item.key);
|
||||
keys.push(...getAllParentKeys(item.children));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean): boolean {
|
||||
return isSearching || isOpened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must stay in the accessible tree when a branch is open.
|
||||
* Nested Collapse height bugs clip these siblings after refresh; this list is the contract.
|
||||
*/
|
||||
export function getVisibleMenuKeys(
|
||||
items: MenuItemType[],
|
||||
openedKeys: Set<string>,
|
||||
isSearching = false,
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
const walk = (nodes: MenuItemType[]) => {
|
||||
for (const node of nodes) {
|
||||
keys.push(node.key);
|
||||
if (node.children?.length && shouldShowMenuChildren(openedKeys.has(node.key), isSearching)) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(items);
|
||||
return keys;
|
||||
}
|
||||
Reference in New Issue
Block a user