From 90b606f865f404b3486fe24c4364a48250833a12 Mon Sep 17 00:00:00 2001 From: shancheas Date: Mon, 31 Aug 2026 16:59:17 +0700 Subject: [PATCH] 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. --- .../layouts/components/sidebar.store.test.ts | 48 ++++++++++ .../apps/main/layouts/components/sidebar.tsx | 87 +++++++++---------- .../layouts/components/sidebar.utils.test.ts | 49 +++++++++++ .../main/layouts/components/sidebar.utils.ts | 42 +++++++++ 4 files changed, 181 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/apps/main/layouts/components/sidebar.store.test.ts create mode 100644 apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts create mode 100644 apps/web/src/apps/main/layouts/components/sidebar.utils.ts diff --git a/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts b/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts new file mode 100644 index 0000000..46bbdb5 --- /dev/null +++ b/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts @@ -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']); + }); +}); diff --git a/apps/web/src/apps/main/layouts/components/sidebar.tsx b/apps/web/src/apps/main/layouts/components/sidebar.tsx index 9dcecb8..88b8407 100644 --- a/apps/web/src/apps/main/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/main/layouts/components/sidebar.tsx @@ -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 ( - } - 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) => ( - - ))} - + <> + } + 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} + + {hasChildren && effectivelyOpened ? ( + + {item.children!.map((child) => ( + + ))} + + ) : 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, diff --git a/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts b/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts new file mode 100644 index 0000000..44e5a5a --- /dev/null +++ b/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/apps/main/layouts/components/sidebar.utils.ts b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts new file mode 100644 index 0000000..448849d --- /dev/null +++ b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts @@ -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, + 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; +}