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:
shancheas
2026-08-31 16:59:17 +07:00
parent 6808a681b7
commit 90b606f865
4 changed files with 181 additions and 45 deletions
@@ -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,39 +85,46 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
);
return (
<NavLink
component={hasChildren ? 'button' : (Link as any)}
to={hasChildren ? undefined : item.path}
label={t(item.label)}
leftSection={<Icon size={18} strokeWidth={1.8} />}
active={isExactActive}
opened={effectivelyOpened}
onChange={handleChange}
variant="light"
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
},
}}
>
{hasChildren &&
item.children!.map((child) => (
<MenuItemExpanded
key={child.key}
item={child}
activeKeys={activeKeys}
isSearching={isSearching}
allParentKeys={allParentKeys}
/>
))}
</NavLink>
<>
<NavLink
component={hasChildren ? 'button' : (Link as any)}
to={hasChildren ? undefined : item.path}
label={t(item.label)}
leftSection={<Icon size={18} strokeWidth={1.8} />}
active={isExactActive}
opened={effectivelyOpened}
onChange={handleChange}
variant="light"
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
},
}}
>
{/* 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}
activeKeys={activeKeys}
isSearching={isSearching}
allParentKeys={allParentKeys}
/>
))}
</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;
}