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 { shortcutsData } from '@repo/ui/constants';
|
||||||
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||||
import { useSidebarStore } from './sidebar.store';
|
import { useSidebarStore } from './sidebar.store';
|
||||||
|
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||||
import { useDebouncedValue } from '@repo/ui/hooks';
|
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 isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
|
||||||
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
||||||
|
|
||||||
const effectivelyOpened = isSearching || isOpened;
|
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(newOpened: boolean) => {
|
(newOpened: boolean) => {
|
||||||
@@ -84,6 +85,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<NavLink
|
<NavLink
|
||||||
component={hasChildren ? 'button' : (Link as any)}
|
component={hasChildren ? 'button' : (Link as any)}
|
||||||
to={hasChildren ? undefined : item.path}
|
to={hasChildren ? undefined : item.path}
|
||||||
@@ -106,8 +108,12 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren &&
|
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
|
||||||
item.children!.map((child) => (
|
{hasChildren ? <></> : undefined}
|
||||||
|
</NavLink>
|
||||||
|
{hasChildren && effectivelyOpened ? (
|
||||||
|
<Box ps="lg">
|
||||||
|
{item.children!.map((child) => (
|
||||||
<MenuItemExpanded
|
<MenuItemExpanded
|
||||||
key={child.key}
|
key={child.key}
|
||||||
item={child}
|
item={child}
|
||||||
@@ -116,7 +122,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
allParentKeys={allParentKeys}
|
allParentKeys={allParentKeys}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,17 +244,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
|||||||
// SidebarMenu Component
|
// 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({
|
export const SidebarMenu = memo(function SidebarMenu({
|
||||||
items,
|
items,
|
||||||
variantOverride,
|
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