From 0f5a1ef1d13f373fc576f0804f6e5656160d2dfc Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:45:57 +0700 Subject: [PATCH] feat: implement sidebar menu persistence using zustand --- .../layouts/components/sidebar.store.ts | 92 +++++++++++ .../modules/layouts/components/sidebar.tsx | 153 +++++++++++++----- apps/web/src/core/storage/local/index.ts | 7 +- .../core-i18n/src/languages/en/common.json | 3 +- .../core-i18n/src/languages/id/common.json | 3 +- 5 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/apps/modules/layouts/components/sidebar.store.ts diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.store.ts b/apps/web/src/apps/modules/layouts/components/sidebar.store.ts new file mode 100644 index 0000000..cc15116 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/sidebar.store.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand'; +import { appStorage, AppStorageKey } from '../../../../core/storage/local'; + +interface SidebarState { + openedKeys: Set; + isInitialized: boolean; + isAllExpanded: boolean; + searchQuery: string; + + // Actions + initializeStorage: (initialKeysFromActive: string[], allParentKeys: string[]) => Promise; + toggleMenu: (key: string, isOpened: boolean, allParentKeys: string[]) => Promise; + expandAll: (allParentKeys: string[]) => Promise; + collapseAll: () => Promise; + syncActiveKeys: (keys: string[], allParentKeys: string[]) => Promise; + setSearchQuery: (query: string) => void; +} + +export const useSidebarStore = create((set, get) => ({ + openedKeys: new Set(), + isInitialized: false, + isAllExpanded: false, + searchQuery: '', + + initializeStorage: async (initialKeysFromActive, allParentKeys) => { + if (get().isInitialized) return; + + const saved = await appStorage.getItem(AppStorageKey.SIDEBAR_OPEN_MENUS); + + if (saved) { + const openedSet = new Set(saved); + const isAllExpanded = saved.length > 0 && allParentKeys.every((k) => openedSet.has(k)); + set({ openedKeys: openedSet, isInitialized: true, isAllExpanded }); + } else { + // First time load: use initial active keys + await appStorage.setItem(AppStorageKey.SIDEBAR_OPEN_MENUS, initialKeysFromActive); + const openedSet = new Set(initialKeysFromActive); + const isAllExpanded = initialKeysFromActive.length > 0 && allParentKeys.every((k) => openedSet.has(k)); + set({ openedKeys: openedSet, isInitialized: true, isAllExpanded }); + } + }, + + toggleMenu: async (key, isOpened, allParentKeys) => { + const prevSet = get().openedKeys; + const nextSet = new Set(prevSet); + + if (isOpened) { + nextSet.add(key); + } else { + nextSet.delete(key); + } + + const nextArr = Array.from(nextSet); + const isAllExpanded = nextArr.length > 0 && allParentKeys.every((k) => nextSet.has(k)); + + set({ openedKeys: nextSet, isAllExpanded }); + await appStorage.setItem(AppStorageKey.SIDEBAR_OPEN_MENUS, nextArr); + }, + + expandAll: async (allParentKeys) => { + const nextSet = new Set(allParentKeys); + set({ openedKeys: nextSet, isAllExpanded: true }); + await appStorage.setItem(AppStorageKey.SIDEBAR_OPEN_MENUS, allParentKeys); + }, + + collapseAll: async () => { + set({ openedKeys: new Set(), isAllExpanded: false }); + await appStorage.setItem(AppStorageKey.SIDEBAR_OPEN_MENUS, []); + }, + + syncActiveKeys: async (keysToSync, allParentKeys) => { + const prevSet = get().openedKeys; + let hasChanges = false; + const nextSet = new Set(prevSet); + + for (const key of keysToSync) { + if (!nextSet.has(key)) { + nextSet.add(key); + hasChanges = true; + } + } + + if (hasChanges) { + const nextArr = Array.from(nextSet); + const isAllExpanded = nextArr.length > 0 && allParentKeys.every((k) => nextSet.has(k)); + set({ openedKeys: nextSet, isAllExpanded }); + await appStorage.setItem(AppStorageKey.SIDEBAR_OPEN_MENUS, nextArr); + } + }, + + setSearchQuery: (query: string) => set({ searchQuery: query }), +})); diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 3fb7189..9dcecb8 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -1,6 +1,18 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput, Button, Group } from '@repo/ui/components'; +import { + Box, + NavLink, + Stack, + Tooltip, + ActionIcon, + Divider, + Menu, + TextInput, + Button, + Group, + Text, +} from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components'; import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X, History, Bookmark } from 'lucide-react'; import { publish } from '@repo/core-events'; @@ -9,6 +21,8 @@ import type { SidebarVariant } from '@repo/ui/components'; 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 { useDebouncedValue } from '@repo/ui/hooks'; // --------------------------------------------------------------------------- // Props @@ -39,17 +53,15 @@ interface SidebarMenuProps { interface MenuItemExpandedProps { item: MenuItemType; activeKeys: Set; - isSearching?: boolean; - expandVersion?: number; - collapseVersion?: number; + isSearching: boolean; + allParentKeys: string[]; } const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSearching, - expandVersion = 0, - collapseVersion = 0, + allParentKeys, }: MenuItemExpandedProps) { const { t } = useTranslation(); const Icon = item.icon; @@ -59,26 +71,27 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ const isExactActive = isActive && !hasChildren; const isParentActive = isActive && hasChildren; - const [opened, setOpened] = useState(isActive); - const isOpened = isSearching || opened; + const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key)); + const toggleMenu = useSidebarStore((state) => state.toggleMenu); - useEffect(() => { - if (expandVersion > 0) setOpened(true); - }, [expandVersion]); + const effectivelyOpened = isSearching || isOpened; - useEffect(() => { - if (collapseVersion > 0) setOpened(false); - }, [collapseVersion]); + const handleChange = useCallback( + (newOpened: boolean) => { + toggleMenu(item.key, newOpened, allParentKeys); + }, + [item.key, toggleMenu, allParentKeys], + ); return ( } + leftSection={} active={isExactActive} - opened={isOpened} - onChange={setOpened} + opened={effectivelyOpened} + onChange={handleChange} variant="light" styles={{ root: { @@ -89,6 +102,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + fontWeight: 500, }, }} > @@ -99,8 +113,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item={child} activeKeys={activeKeys} isSearching={isSearching} - expandVersion={expandVersion} - collapseVersion={collapseVersion} + allParentKeys={allParentKeys} /> ))} @@ -136,7 +149,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = size="lg" aria-label={t(item.label)} > - + ); @@ -153,6 +166,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 240, + fontWeight: 500, }, }} > @@ -170,7 +184,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = size="lg" aria-label={t(item.label)} > - + ) : ( { + 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, @@ -238,23 +263,27 @@ export const SidebarMenu = memo(function SidebarMenu({ const variant = variantOverride ?? contextVariant; const isMini = variant === 'mini'; - const [searchQuery, setSearchQuery] = useState(''); + const searchQuery = useSidebarStore((state) => state.searchQuery); + const setSearchQuery = useSidebarStore((state) => state.setSearchQuery); + const [debouncedSearchQuery] = useDebouncedValue(searchQuery, 200); const inputRef = useRef(null); - const isSearching = searchQuery.trim().length > 0; + const isSearching = debouncedSearchQuery.trim().length > 0; - const [expandVersion, setExpandVersion] = useState(0); - const [collapseVersion, setCollapseVersion] = useState(0); - const [isAllExpanded, setIsAllExpanded] = useState(false); + const allParentKeys = useMemo(() => getAllParentKeys(items), [items]); + const isInitialized = useSidebarStore((state) => state.isInitialized); + const isAllExpanded = useSidebarStore((state) => state.isAllExpanded); + const initializeStorage = useSidebarStore((state) => state.initializeStorage); + const expandAll = useSidebarStore((state) => state.expandAll); + const collapseAll = useSidebarStore((state) => state.collapseAll); + const syncActiveKeys = useSidebarStore((state) => state.syncActiveKeys); const handleToggleExpandAll = useCallback(() => { if (isAllExpanded) { - setCollapseVersion((v) => v + 1); - setIsAllExpanded(false); + collapseAll(); } else { - setExpandVersion((v) => v + 1); - setIsAllExpanded(true); + expandAll(allParentKeys); } - }, [isAllExpanded]); + }, [isAllExpanded, collapseAll, expandAll, allParentKeys]); // Recursive active state calculation const activeKeys = useMemo(() => { @@ -299,11 +328,33 @@ export const SidebarMenu = memo(function SidebarMenu({ return keys; }, [pathname, items]); + // Sync initial state to local storage or auto-expand on navigation + useEffect(() => { + const parentKeysToSync: string[] = []; + const checkActiveParents = (item: MenuItemType) => { + if (item.children && item.children.length > 0) { + if (activeKeys.has(item.key)) { + parentKeysToSync.push(item.key); + } + item.children.forEach(checkActiveParents); + } + }; + items.forEach(checkActiveParents); + + if (!isInitialized) { + initializeStorage(parentKeysToSync, allParentKeys); + } else { + if (parentKeysToSync.length > 0) { + syncActiveKeys(parentKeysToSync, allParentKeys); + } + } + }, [activeKeys, items, isInitialized, initializeStorage, syncActiveKeys, allParentKeys]); + // Client-side recursive filtering logic const filteredItems = useMemo(() => { if (!isSearching) return items; - const query = searchQuery.toLowerCase(); + const query = debouncedSearchQuery.toLowerCase(); const filterItem = (item: MenuItemType): MenuItemType | null => { const isMatch = t(item.label).toLowerCase().includes(query); @@ -327,7 +378,7 @@ export const SidebarMenu = memo(function SidebarMenu({ }; return items.map(filterItem).filter((item): item is MenuItemType => item !== null); - }, [items, isSearching, searchQuery]); + }, [items, isSearching, debouncedSearchQuery, t]); const handleToggle = useCallback(() => { setSidebarVariant(isMini ? 'expanded' : 'mini'); @@ -581,16 +632,32 @@ export const SidebarMenu = memo(function SidebarMenu({ )} - {filteredItems.map((item) => ( - - ))} + {filteredItems.length === 0 && isSearching ? ( + + + + {t('common:system_menu.notFound')} + + + + ) : ( + filteredItems.map((item) => ( + + )) + )} {withToggle && ( diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index d8e77b8..8e9b3ed 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -6,6 +6,7 @@ export const AppStorageKey = { ACCESS_TOKEN: 'access_token', REFRESH_TOKEN: 'refresh_token', USER_ID: 'u_id', + SIDEBAR_OPEN_MENUS: 'sidebar_open_menus', } as const; export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; @@ -26,7 +27,11 @@ export const APP_STORAGE_ENCRYPTED_KEYS = new Set([ AppStorageKey.REFRESH_TOKEN, ]); -export const APP_STORAGE_PLAIN_KEYS = new Set([AppStorageKey.LANGUAGE, AppStorageKey.THEME]); +export const APP_STORAGE_PLAIN_KEYS = new Set([ + AppStorageKey.LANGUAGE, + AppStorageKey.THEME, + AppStorageKey.SIDEBAR_OPEN_MENUS, +]); export const APP_DATABASE_ENCRYPTED_KEYS = new Set([]); diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 148c95d..79f4bf1 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -154,7 +154,8 @@ }, "system_menu": { "history": "History", - "bookmark": "Bookmark" + "bookmark": "Bookmark", + "notFound": "Menu not found" }, "fields": { "id": "ID", diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 6a5f8e4..d570b18 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -154,7 +154,8 @@ }, "system_menu": { "history": "Riwayat", - "bookmark": "Bookmark" + "bookmark": "Bookmark", + "notFound": "Menu tidak ditemukan" }, "fields": { "id": "ID",