Integrate TableStateContext for enhanced table state management

- Introduced TableStateContext to manage table filters and sorting, allowing for persistence across sessions and URL parameters.
- Updated ObjectTable and DashboardBreadcrumb components to utilize the new context for improved filter handling and user experience.
- Refactored DashboardLayout to include TableStateProvider, ensuring consistent state management across the dashboard components.
This commit is contained in:
Tom Butcher 2026-08-01 12:38:21 +01:00
parent 84b7197bb9
commit bc465f7202
4 changed files with 494 additions and 50 deletions

View File

@ -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 (
<MessageProvider>
<Layout
style={{ height: 'var(--unit-100vh)' }}
className={isDarkMode ? 'dark-mode' : 'light-mode'}
>
<DashboardNavigation />
<Layout>
{isProduction ? (
<ProductionSidebar />
) : isInventory ? (
<InventorySidebar />
) : isFinance ? (
<FinanceSidebar />
) : isSales ? (
<SalesSidebar />
) : isManagement ? (
<ManagementSidebar />
) : isDeveloper ? (
<DeveloperSidebar />
) : (
<ProductionSidebar /> // Default to production sidebar
)}
<Layout style={{ padding: '24px' }}>
<Content>
<Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'>
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
</Flex>
<TableStateProvider>
<Layout
style={{ height: 'var(--unit-100vh)' }}
className={isDarkMode ? 'dark-mode' : 'light-mode'}
>
<DashboardNavigation />
<Layout>
{isProduction ? (
<ProductionSidebar />
) : isInventory ? (
<InventorySidebar />
) : isFinance ? (
<FinanceSidebar />
) : isSales ? (
<SalesSidebar />
) : isManagement ? (
<ManagementSidebar />
) : isDeveloper ? (
<DeveloperSidebar />
) : (
<ProductionSidebar /> // Default to production sidebar
)}
<Layout style={{ padding: '24px' }}>
<Content>
<Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'>
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
</Flex>
{children}
</Flex>
</Content>
{children}
</Flex>
</Content>
</Layout>
</Layout>
</Layout>
</Layout>
</TableStateProvider>
</MessageProvider>
)
}

View File

@ -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: (
<Link to={url} style={{ padding: '0 12px' }}>
<Link
to={url}
style={{
padding: '0 12px',
display: 'inline-flex',
alignItems: 'center',
gap: 6
}}
>
{name}
{showFilterIcon && (
<FilterIcon style={{ fontSize: 12, opacity: 0.65 }} />
)}
</Link>
),
key: url

View File

@ -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
}

View File

@ -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 (
<TableStateContext.Provider value={value}>
{children}
</TableStateContext.Provider>
)
}
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