diff --git a/src/components/Dashboard/Layout.jsx b/src/components/Dashboard/Layout.jsx index 56160aa..49a7b7b 100644 --- a/src/components/Dashboard/Layout.jsx +++ b/src/components/Dashboard/Layout.jsx @@ -12,6 +12,7 @@ import DashboardBreadcrumb from './common/DashboardBreadcrumb' import DeveloperSidebar from './Developer/DeveloperSidebar' import { useThemeContext } from './context/ThemeContext' import { MessageProvider } from './context/MessageContext' +import { TableStateProvider } from './context/TableStateContext' const { Content } = Layout @@ -28,40 +29,42 @@ const DashboardLayout = ({ children }) => { return ( - - - - {isProduction ? ( - - ) : isInventory ? ( - - ) : isFinance ? ( - - ) : isSales ? ( - - ) : isManagement ? ( - - ) : isDeveloper ? ( - - ) : ( - // Default to production sidebar - )} - - - - - - + + + + + {isProduction ? ( + + ) : isInventory ? ( + + ) : isFinance ? ( + + ) : isSales ? ( + + ) : isManagement ? ( + + ) : isDeveloper ? ( + + ) : ( + // Default to production sidebar + )} + + + + + + - {children} - - + {children} + + + - + ) } diff --git a/src/components/Dashboard/common/DashboardBreadcrumb.jsx b/src/components/Dashboard/common/DashboardBreadcrumb.jsx index b042a15..3575588 100644 --- a/src/components/Dashboard/common/DashboardBreadcrumb.jsx +++ b/src/components/Dashboard/common/DashboardBreadcrumb.jsx @@ -3,7 +3,9 @@ import { Breadcrumb, Button, Flex, Space } from 'antd' import { Link, useLocation, useNavigate } from 'react-router-dom' import ArrowLeftIcon from '../../Icons/ArrowLeftIcon' import ArrowRightIcon from '../../Icons/ArrowRightIcon' +import FilterIcon from '../../Icons/FilterIcon' import { getModelByName } from '../../../database/ObjectModels' +import { useTableState } from '../context/TableStateContext' const breadcrumbNameMap = { production: 'Production', @@ -26,6 +28,7 @@ const mainSections = ['production', 'inventory', 'management', 'developer'] const DashboardBreadcrumb = () => { const location = useLocation() const navigate = useNavigate() + const { hasPageFilter, hasStoredFilter } = useTableState() const pathSnippets = location.pathname.split('/').filter((i) => i) function segmentToModel(segment) { @@ -48,6 +51,9 @@ const DashboardBreadcrumb = () => { const model = segmentToModel(segment) const modelLabelPlural = model?.label ? `${model.label}s` : null const name = breadcrumbNameMap[segment] || modelLabelPlural || segment + const showFilterIcon = + hasPageFilter(url) || + (model?.name && model.name !== 'unknown' && hasStoredFilter(model.name)) if (isMainSection) { return { title: ( @@ -61,8 +67,19 @@ const DashboardBreadcrumb = () => { return { title: ( - + {name} + {showFilterIcon && ( + + )} ), key: url diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx index bcf2553..8647ae8 100644 --- a/src/components/Dashboard/common/ObjectTable.jsx +++ b/src/components/Dashboard/common/ObjectTable.jsx @@ -39,13 +39,14 @@ import ObjectCard from './ObjectCard' import FilterSidebar from './FilterSidebar' import XMarkIcon from '../../Icons/XMarkIcon' import CheckIcon from '../../Icons/CheckIcon' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useLocation } from 'react-router-dom' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' import { AuthContext } from '../context/AuthContext' import { ElectronContext } from '../context/ElectronContext' import ActionsIcon from '../../Icons/ActionsIcon' import FilterIcon from '../../Icons/FilterIcon' import ScrollBox from './ScrollBox' +import { useTableStatePersistence } from '../context/TableStateContext' const logger = loglevel.getLogger('DasboardTable') logger.setLevel(config.logLevel) @@ -117,6 +118,14 @@ const ObjectTable = forwardRef( showFilterSidebar = false, expandHeight = false, showActions = true, + saveFilterInSession = false, + saveFilterInUrl = false, + useFilterInSession = false, + useFilterInUrl = false, + saveSortInSession = false, + saveSortInUrl = false, + useSortInSession = false, + useSortInUrl = false, onRowAction }, ref @@ -139,6 +148,26 @@ const ObjectTable = forwardRef( } = useContext(ApiServerContext) const isMobile = useMediaQuery({ maxWidth: 768 }) const navigate = useNavigate() + const location = useLocation() + const { + getPersistedFilter, + getPersistedSorter, + persistFilter, + persistTableState, + registerPageFilter, + getActiveFilterValues + } = useTableStatePersistence({ + scope: type, + pagePath: location.pathname, + saveFilterInSession, + saveFilterInUrl, + useFilterInSession, + useFilterInUrl, + saveSortInSession, + saveSortInUrl, + useSortInSession, + useSortInUrl + }) var adjustedScrollHeight = scrollHeight if (isMobile) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)' @@ -160,6 +189,7 @@ const ObjectTable = forwardRef( const activeFilterRef = useRef({}) const tableSorterRef = useRef({}) const [sidebarFilter, setSidebarFilter] = useState({}) + const [tableSorter, setTableSorter] = useState({}) const [initialized, setInitialized] = useState(false) // Table state @@ -781,8 +811,24 @@ const ObjectTable = forwardRef( ) const loadInitialPage = useCallback(async () => { - loadPage(initialPage) - }, [initialPage, loadPage]) + const filter = getPersistedFilter() + const sorter = getPersistedSorter() + const activeFilter = getActiveFilterValues(filter) + + if (Object.keys(filter).length > 0) { + setSidebarFilter(filter) + } + if (sorter.field) { + tableSorterRef.current = sorter + setTableSorter(sorter) + } + + loadPage( + initialPage, + activeFilter, + sorter.field ? sorter : null + ) + }, [getPersistedFilter, getPersistedSorter, initialPage, loadPage]) useImperativeHandle(ref, () => ({ reload, @@ -823,6 +869,7 @@ const ObjectTable = forwardRef( activeFilterRef.current = {} tableSorterRef.current = {} setSidebarFilter({}) + setTableSorter({}) setInitialized(false) setLoading(true) setLazyLoading(false) @@ -830,6 +877,11 @@ const ObjectTable = forwardRef( } }, [type, masterFilter]) + useEffect(() => { + registerPageFilter(sidebarFilter) + return () => registerPageFilter({}) + }, [sidebarFilter, registerPageFilter]) + const getFilterDropdown = ({ setSelectedKeys, selectedKeys, @@ -866,14 +918,6 @@ const ObjectTable = forwardRef( ) } - const getActiveFilter = useCallback((filterState) => { - const active = {} - Object.entries(filterState).forEach(([k, v]) => { - if (v !== '' && v !== undefined) active[k] = v - }) - return active - }, []) - const handleTableChange = (pagination, filters, sorter) => { const next = { ...sidebarFilter } @@ -885,23 +929,28 @@ const ObjectTable = forwardRef( } }) - setSidebarFilter(next) - setPages([]) - setLoading(true) - loadPage(initialPage, getActiveFilter(next), { + const nextSorter = { field: sorter.columnKey, order: sorter.order - }) + } + + setSidebarFilter(next) + setTableSorter(nextSorter) + persistTableState(next, nextSorter) + setPages([]) + setLoading(true) + loadPage(initialPage, getActiveFilterValues(next), nextSorter) } const handleSidebarFilterChange = useCallback( (newSidebarFilter) => { setSidebarFilter(newSidebarFilter) + persistFilter(newSidebarFilter) setPages([]) setLoading(true) - loadPage(initialPage, getActiveFilter(newSidebarFilter)) + loadPage(initialPage, getActiveFilterValues(newSidebarFilter)) }, - [initialPage, loadPage, getActiveFilter] + [initialPage, loadPage, persistFilter] ) const modelProperties = getModelProperties(type) @@ -988,6 +1037,8 @@ const ObjectTable = forwardRef( const columnConfig = { sorter: isSortable ? { multiple: 1 } : undefined, + sortOrder: + tableSorter?.field === prop.name ? tableSorter.order : null, title: prop.label, width: prop.columnWidth || width, fixed: isMobile ? undefined : fixed, @@ -1306,6 +1357,14 @@ ObjectTable.propTypes = { showFilterSidebar: PropTypes.bool, expandHeight: PropTypes.bool, showActions: PropTypes.bool, + saveFilterInSession: PropTypes.bool, + saveFilterInUrl: PropTypes.bool, + useFilterInSession: PropTypes.bool, + useFilterInUrl: PropTypes.bool, + saveSortInSession: PropTypes.bool, + saveSortInUrl: PropTypes.bool, + useSortInSession: PropTypes.bool, + useSortInUrl: PropTypes.bool, onRowAction: PropTypes.func } diff --git a/src/components/Dashboard/context/TableStateContext.jsx b/src/components/Dashboard/context/TableStateContext.jsx new file mode 100644 index 0000000..59981db --- /dev/null +++ b/src/components/Dashboard/context/TableStateContext.jsx @@ -0,0 +1,365 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState +} from 'react' +import { useSearchParams } from 'react-router-dom' +import PropTypes from 'prop-types' + +const TableStateContext = createContext() + +export const FILTER_URL_PARAM = 'filter' +export const SORT_URL_PARAM = 'sort' + +const getSessionFilterKey = (scope) => `tableState:${scope}:filter` +const getSessionSortKey = (scope) => `tableState:${scope}:sort` + +export const getActiveFilterValues = (filterState) => { + const active = {} + Object.entries(filterState || {}).forEach(([k, v]) => { + if (v !== '' && v !== undefined) active[k] = v + }) + return active +} + +const parseJsonParam = (raw) => { + if (!raw) return null + try { + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed !== null ? parsed : null + } catch { + return null + } +} + +const readFilterFromUrl = (searchParams) => + parseJsonParam(searchParams.get(FILTER_URL_PARAM)) + +const readSortFromUrl = (searchParams) => { + const parsed = parseJsonParam(searchParams.get(SORT_URL_PARAM)) + if (!parsed?.field || !parsed?.order) return null + return { field: parsed.field, order: parsed.order } +} + +const readFilterFromSession = (scope) => { + try { + return parseJsonParam(sessionStorage.getItem(getSessionFilterKey(scope))) + } catch { + return null + } +} + +export const hasStoredSessionFilter = (scope) => { + if (!scope) return false + return Object.keys(getActiveFilterValues(readFilterFromSession(scope) || {})).length > 0 +} + +const readSortFromSession = (scope) => { + try { + const parsed = parseJsonParam(sessionStorage.getItem(getSessionSortKey(scope))) + if (!parsed?.field || !parsed?.order) return null + return { field: parsed.field, order: parsed.order } + } catch { + return null + } +} + +const writeFilterToSession = (scope, filter) => { + const key = getSessionFilterKey(scope) + if (Object.keys(filter).length > 0) { + sessionStorage.setItem(key, JSON.stringify(filter)) + } else { + sessionStorage.removeItem(key) + } +} + +const writeSortToSession = (scope, sorter) => { + const key = getSessionSortKey(scope) + if (sorter?.field && sorter?.order) { + sessionStorage.setItem(key, JSON.stringify(sorter)) + } else { + sessionStorage.removeItem(key) + } +} + +const normalizeSorter = (sorter) => { + if (sorter?.field && sorter?.order) { + return { field: sorter.field, order: sorter.order } + } + return {} +} + +export const TableStateProvider = ({ children }) => { + const [searchParams, setSearchParams] = useSearchParams() + const [pageFilters, setPageFilters] = useState({}) + + const getPersistedFilter = useCallback( + (scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => { + if (useFilterInUrl) { + const fromUrl = readFilterFromUrl(searchParams) + if (fromUrl) return fromUrl + } + if (useFilterInSession) { + const fromSession = readFilterFromSession(scope) + if (fromSession) return fromSession + } + return {} + }, + [searchParams] + ) + + const getPersistedSorter = useCallback( + (scope, { useSortInUrl = false, useSortInSession = false } = {}) => { + if (useSortInUrl) { + const fromUrl = readSortFromUrl(searchParams) + if (fromUrl) return fromUrl + } + if (useSortInSession) { + const fromSession = readSortFromSession(scope) + if (fromSession) return fromSession + } + return {} + }, + [searchParams] + ) + + const persistFilter = useCallback( + ( + scope, + filterState, + { saveFilterInSession = false, saveFilterInUrl = false } = {} + ) => { + const active = getActiveFilterValues(filterState) + if (saveFilterInSession) { + writeFilterToSession(scope, active) + } + if (saveFilterInUrl) { + const next = new URLSearchParams(searchParams) + if (Object.keys(active).length > 0) { + next.set(FILTER_URL_PARAM, JSON.stringify(active)) + } else { + next.delete(FILTER_URL_PARAM) + } + setSearchParams(next, { replace: true }) + } + }, + [searchParams, setSearchParams] + ) + + const persistSort = useCallback( + ( + scope, + sorter, + { saveSortInSession = false, saveSortInUrl = false } = {} + ) => { + const nextSorter = normalizeSorter(sorter) + if (saveSortInSession) { + writeSortToSession(scope, nextSorter) + } + if (saveSortInUrl) { + const next = new URLSearchParams(searchParams) + if (nextSorter.field && nextSorter.order) { + next.set(SORT_URL_PARAM, JSON.stringify(nextSorter)) + } else { + next.delete(SORT_URL_PARAM) + } + setSearchParams(next, { replace: true }) + } + }, + [searchParams, setSearchParams] + ) + + const persistTableState = useCallback( + ( + scope, + filterState, + sorter, + { + saveFilterInSession = false, + saveFilterInUrl = false, + saveSortInSession = false, + saveSortInUrl = false + } = {} + ) => { + const active = getActiveFilterValues(filterState) + const nextSorter = normalizeSorter(sorter) + + if (saveFilterInSession) { + writeFilterToSession(scope, active) + } + if (saveSortInSession) { + writeSortToSession(scope, nextSorter) + } + + if (saveFilterInUrl || saveSortInUrl) { + const next = new URLSearchParams(searchParams) + if (saveFilterInUrl) { + if (Object.keys(active).length > 0) { + next.set(FILTER_URL_PARAM, JSON.stringify(active)) + } else { + next.delete(FILTER_URL_PARAM) + } + } + if (saveSortInUrl) { + if (nextSorter.field && nextSorter.order) { + next.set(SORT_URL_PARAM, JSON.stringify(nextSorter)) + } else { + next.delete(SORT_URL_PARAM) + } + } + setSearchParams(next, { replace: true }) + } + }, + [searchParams, setSearchParams] + ) + + const setPageFilter = useCallback((path, filter) => { + const active = getActiveFilterValues(filter) + setPageFilters((prev) => { + const hasActive = Object.keys(active).length > 0 + if (!hasActive) { + if (!(path in prev)) return prev + const next = { ...prev } + delete next[path] + return next + } + return { ...prev, [path]: active } + }) + }, []) + + const hasPageFilter = useCallback( + (path) => Object.keys(pageFilters[path] || {}).length > 0, + [pageFilters] + ) + + const hasStoredFilter = useCallback((scope) => hasStoredSessionFilter(scope), []) + + const value = useMemo( + () => ({ + getPersistedFilter, + getPersistedSorter, + persistFilter, + persistSort, + persistTableState, + setPageFilter, + hasPageFilter, + hasStoredFilter, + pageFilters + }), + [ + getPersistedFilter, + getPersistedSorter, + persistFilter, + persistSort, + persistTableState, + setPageFilter, + hasPageFilter, + hasStoredFilter, + pageFilters + ] + ) + + return ( + + {children} + + ) +} + +export const useTableState = () => { + const context = useContext(TableStateContext) + if (!context) { + throw new Error('useTableState must be used within a TableStateProvider') + } + return context +} + +export const useTableStatePersistence = ({ + scope, + pagePath, + saveFilterInSession = false, + saveFilterInUrl = false, + useFilterInSession = false, + useFilterInUrl = false, + saveSortInSession = false, + saveSortInUrl = false, + useSortInSession = false, + useSortInUrl = false +}) => { + const { + getPersistedFilter: getPersistedFilterCtx, + getPersistedSorter: getPersistedSorterCtx, + persistFilter: persistFilterCtx, + persistSort: persistSortCtx, + persistTableState: persistTableStateCtx, + setPageFilter + } = useTableState() + + const readOptions = useMemo( + () => ({ useFilterInUrl, useFilterInSession, useSortInUrl, useSortInSession }), + [useFilterInUrl, useFilterInSession, useSortInUrl, useSortInSession] + ) + + const saveOptions = useMemo( + () => ({ + saveFilterInSession, + saveFilterInUrl, + saveSortInSession, + saveSortInUrl + }), + [saveFilterInSession, saveFilterInUrl, saveSortInSession, saveSortInUrl] + ) + + const getPersistedFilter = useCallback( + () => getPersistedFilterCtx(scope, readOptions), + [getPersistedFilterCtx, scope, readOptions] + ) + + const getPersistedSorter = useCallback( + () => getPersistedSorterCtx(scope, readOptions), + [getPersistedSorterCtx, scope, readOptions] + ) + + const persistFilter = useCallback( + (filterState) => persistFilterCtx(scope, filterState, saveOptions), + [persistFilterCtx, scope, saveOptions] + ) + + const persistSort = useCallback( + (sorter) => persistSortCtx(scope, sorter, saveOptions), + [persistSortCtx, scope, saveOptions] + ) + + const persistTableState = useCallback( + (filterState, sorter) => + persistTableStateCtx(scope, filterState, sorter, saveOptions), + [persistTableStateCtx, scope, saveOptions] + ) + + const registerPageFilter = useCallback( + (filterState) => { + if (pagePath) { + setPageFilter(pagePath, filterState) + } + }, + [pagePath, setPageFilter] + ) + + return { + getPersistedFilter, + getPersistedSorter, + persistFilter, + persistSort, + persistTableState, + registerPageFilter, + getActiveFilterValues + } +} + +TableStateProvider.propTypes = { + children: PropTypes.node.isRequired +} + +export default TableStateContext