From a2616f2b2da1bd1a1665c68d41b4aaf0d5330422 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:44:05 +0700 Subject: [PATCH 1/3] feat: refine sidebar icon styling --- .../src/apps/modules/layouts/components/sidebar.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 00693b0..3fb7189 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -136,7 +136,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = size="lg" aria-label={t(item.label)} > - + ); @@ -170,7 +170,7 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = size="lg" aria-label={t(item.label)} > - + ) : ( - {isAllExpanded ? : } + {isAllExpanded ? ( + + ) : ( + + )} From 75e0424e36ff7946a63b31859354e0db79d3d910 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:02:58 +0700 Subject: [PATCH 2/3] feat: implement persistent AG Grid column state --- .../full-page/presentation/store/index.ts | 3 + .../components/data-table/index.tsx | 77 ++++++++++++++++++- .../enterprise-module/entities/entity.ts | 5 ++ .../providers/module.provider.tsx | 6 +- 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/apps/web/src/apps/modules/example/full-page/presentation/store/index.ts b/apps/web/src/apps/modules/example/full-page/presentation/store/index.ts index d347059..f55dce7 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/store/index.ts +++ b/apps/web/src/apps/modules/example/full-page/presentation/store/index.ts @@ -16,4 +16,7 @@ export const fullPageStore = create((set) => ({ privileges: [], setPrivileges: (privileges) => set({ privileges }), + + tableConfig: null, + setTableConfig: (config) => set({ tableConfig: config }), })); diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx index f98ba8f..c19676b 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -10,6 +10,10 @@ import { MenuItemDef, DefaultMenuItem, StatusBar, + ColumnMovedEvent, + ColumnResizedEvent, + ColumnVisibleEvent, + SortChangedEvent, } from 'ag-grid-community'; import { Box, Group, TextInput, Indicator, CloseButton } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; @@ -181,6 +185,12 @@ export function EnterpriseDataTable(props: EnterpriseDataT searchKey = 'search', filterConfig, + // Grid config props + onColumnMoved, + onColumnResized, + onColumnVisible, + onSortChanged, + // Bulk action props customBulkActions, batchSize, @@ -200,8 +210,16 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); - const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = - useEnterpriseModuleSelectionContext(); + const { + selectedRows, + setSelectedRows, + metaData, + setMetaData, + filterData, + setFilterData, + tableConfig, + setTableConfig, + } = useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); const { config } = useEnterpriseModuleConfigContext(); @@ -791,6 +809,11 @@ export function EnterpriseDataTable(props: EnterpriseDataT setSelectedRows([]); if (params.api) { + // Restore grid configuration on mount + if (tableConfig) { + params.api.applyColumnState({ state: tableConfig, applyOrder: true }); + } + // Attach the server-side datasource to the grid API params.api.setGridOption('serverSideDatasource', datasource); @@ -803,7 +826,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT } } }, - [datasource, setSelectedRows, isPaginated, metaData], + [datasource, setSelectedRows, isPaginated, metaData, tableConfig], ); // Triggered whenever the row selection in the grid changes @@ -817,6 +840,50 @@ export function EnterpriseDataTable(props: EnterpriseDataT [setSelectedRows], ); + // --------------------------------------------------------------------------- + // Save Grid Configuration State Handlers + // --------------------------------------------------------------------------- + const saveGridState = useCallback(() => { + if (!gridApiRef.current || !setTableConfig) return; + const state = gridApiRef.current.getColumnState(); + setTableConfig(state); + }, [setTableConfig]); + + const handleColumnMoved = useCallback( + (e: ColumnMovedEvent) => { + saveGridState(); + if (onColumnMoved) onColumnMoved(e); + }, + [saveGridState, onColumnMoved], + ); + + const handleColumnResized = useCallback( + (e: ColumnResizedEvent) => { + // Only save when resizing is finished to prevent excessive re-renders + if (e.finished) { + saveGridState(); + } + if (onColumnResized) onColumnResized(e); + }, + [saveGridState, onColumnResized], + ); + + const handleColumnVisible = useCallback( + (e: ColumnVisibleEvent) => { + saveGridState(); + if (onColumnVisible) onColumnVisible(e); + }, + [saveGridState, onColumnVisible], + ); + + const handleSortChanged = useCallback( + (e: SortChangedEvent) => { + saveGridState(); + if (onSortChanged) onSortChanged(e); + }, + [saveGridState, onSortChanged], + ); + // Configures the context menu items available when right-clicking a cell const getContextMenuItems = useCallback((): | (DefaultMenuItem | MenuItemDef)[] @@ -923,6 +990,10 @@ export function EnterpriseDataTable(props: EnterpriseDataT enableCellTextSelection={true} onSelectionChanged={handleSelectionChanged} onGridReady={onGridReady} + onColumnMoved={handleColumnMoved} + onColumnResized={handleColumnResized} + onColumnVisible={handleColumnVisible} + onSortChanged={handleSortChanged} getContextMenuItems={getContextMenuItems} statusBar={statusBar} domLayout={domLayout} diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index 94f4a0b..711b43a 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -4,6 +4,7 @@ import type { ZodType } from 'zod'; import type { BaseEntity, BaseRemoteDataServices, StandardPaginationMeta } from '@repo/core-api/data-services'; import type { ModulePageHeaderProps } from '../components/module-page-header'; import { PageActionsProps } from '../../../components'; +import { ColumnState } from 'ag-grid-community'; // --------------------------------------------------------------------------- // Module Constants & Base Types @@ -140,6 +141,8 @@ export interface SelectionSlice< setMetaData: (data: TMeta | null) => void; filterData: TFilter | null; setFilterData: (data: TFilter | null) => void; + tableConfig: ColumnState[] | null; + setTableConfig: (config: ColumnState[] | null) => void; } /** @@ -156,6 +159,8 @@ export interface EnterpriseModuleState< setFilterData: (data: TFilter | null) => void; selectedRows: E[]; setSelectedRows: (rows: E[]) => void; + tableConfig: ColumnState[] | null; + setTableConfig: (config: ColumnState[] | null) => void; privileges: string[]; setPrivileges: (privileges: string[]) => void; } diff --git a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx index 3d405bb..e424703 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx @@ -82,6 +82,8 @@ export function EnterpriseModuleProvider< const setMetaData = store((state: S) => state.setMetaData); const filterData = store((state: S) => state.filterData); const setFilterData = store((state: S) => state.setFilterData); + const tableConfig = store((state: S) => state.tableConfig); + const setTableConfig = store((state: S) => state.setTableConfig); const selectionSlice = useMemo( () => ({ @@ -91,8 +93,10 @@ export function EnterpriseModuleProvider< setMetaData, filterData, setFilterData, + tableConfig, + setTableConfig, }), - [selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData], + [selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData, tableConfig, setTableConfig], ); // --------------------------------------------------------------------------- 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 3/3] 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",