feat: implement sidebar menu persistence using zustand
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { create } from 'zustand';
|
||||
import { appStorage, AppStorageKey } from '../../../../core/storage/local';
|
||||
|
||||
interface SidebarState {
|
||||
openedKeys: Set<string>;
|
||||
isInitialized: boolean;
|
||||
isAllExpanded: boolean;
|
||||
searchQuery: string;
|
||||
|
||||
// Actions
|
||||
initializeStorage: (initialKeysFromActive: string[], allParentKeys: string[]) => Promise<void>;
|
||||
toggleMenu: (key: string, isOpened: boolean, allParentKeys: string[]) => Promise<void>;
|
||||
expandAll: (allParentKeys: string[]) => Promise<void>;
|
||||
collapseAll: () => Promise<void>;
|
||||
syncActiveKeys: (keys: string[], allParentKeys: string[]) => Promise<void>;
|
||||
setSearchQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>((set, get) => ({
|
||||
openedKeys: new Set<string>(),
|
||||
isInitialized: false,
|
||||
isAllExpanded: false,
|
||||
searchQuery: '',
|
||||
|
||||
initializeStorage: async (initialKeysFromActive, allParentKeys) => {
|
||||
if (get().isInitialized) return;
|
||||
|
||||
const saved = await appStorage.getItem<string[]>(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 }),
|
||||
}));
|
||||
@@ -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<string>;
|
||||
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 (
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={t(item.label)}
|
||||
leftSection={<Icon size={18} />}
|
||||
leftSection={<Icon size={18} strokeWidth={1.8} />}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
@@ -136,7 +149,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
||||
size="lg"
|
||||
aria-label={t(item.label)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.6} />
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -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)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.6} />
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Menu.Item
|
||||
@@ -222,6 +236,17 @@ 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,
|
||||
@@ -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<HTMLInputElement>(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({
|
||||
)}
|
||||
|
||||
<Box p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredItems.map((item) => (
|
||||
{filteredItems.length === 0 && isSearching ? (
|
||||
<Box
|
||||
p="md"
|
||||
ta="center"
|
||||
style={{
|
||||
border: '1px dashed var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('common:system_menu.notFound')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<MenuItemExpanded
|
||||
key={item.key}
|
||||
item={item}
|
||||
activeKeys={activeKeys}
|
||||
isSearching={isSearching}
|
||||
expandVersion={expandVersion}
|
||||
collapseVersion={collapseVersion}
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{withToggle && (
|
||||
|
||||
@@ -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<AppStorageKeyValue>([
|
||||
AppStorageKey.REFRESH_TOKEN,
|
||||
]);
|
||||
|
||||
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([AppStorageKey.LANGUAGE, AppStorageKey.THEME]);
|
||||
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
||||
AppStorageKey.LANGUAGE,
|
||||
AppStorageKey.THEME,
|
||||
AppStorageKey.SIDEBAR_OPEN_MENUS,
|
||||
]);
|
||||
|
||||
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@
|
||||
},
|
||||
"system_menu": {
|
||||
"history": "History",
|
||||
"bookmark": "Bookmark"
|
||||
"bookmark": "Bookmark",
|
||||
"notFound": "Menu not found"
|
||||
},
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
|
||||
@@ -154,7 +154,8 @@
|
||||
},
|
||||
"system_menu": {
|
||||
"history": "Riwayat",
|
||||
"bookmark": "Bookmark"
|
||||
"bookmark": "Bookmark",
|
||||
"notFound": "Menu tidak ditemukan"
|
||||
},
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
|
||||
Reference in New Issue
Block a user