Tom Butcher d348d6f2b3 Enhance Navigation Context and Table State Management
- Introduced NavigationTabIdContext to improve tab identification and management within the Dashboard.
- Updated TableStateProvider to utilize the new context for better handling of tab-specific state, particularly in Electron environments.
- Enhanced ObjectListViewProvider to integrate persisted view handling based on the new context, improving user experience across different navigation scenarios.
- Refactored various components to streamline state management and improve overall performance.
2026-09-21 00:05:14 +01:00

881 lines
25 KiB
JavaScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useSearchParams } from 'react-router-dom'
import PropTypes from 'prop-types'
import {
NavigationTabIdContext,
useNavigationTabs
} from './NavigationTabsContext'
const TableStateContext = createContext(null)
// eslint-disable-next-line react-refresh/only-export-components
export { TableStateContext }
export const FILTER_URL_PARAM = 'filter'
export const SORT_URL_PARAM = 'sort'
export const VIEW_URL_PARAM = 'viewId'
const getSessionFilterKey = (scope) => `tableState:${scope}:filter`
const getSessionSortKey = (scope) => `tableState:${scope}:sort`
const getSessionListFilterKey = (scope) => `tableState:${scope}:listFilter`
const getSessionListSortKey = (scope) => `tableState:${scope}:listSort`
const getSessionListViewKey = (scope) => `tableState:${scope}:listView`
// eslint-disable-next-line react-refresh/only-export-components
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 readViewFromUrl = (searchParams) => searchParams.get(VIEW_URL_PARAM) || null
const readFilterFromSession = (scope) => {
try {
return parseJsonParam(sessionStorage.getItem(getSessionFilterKey(scope)))
} catch {
return null
}
}
// eslint-disable-next-line react-refresh/only-export-components
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 readListFilterFromSession = (scope) => {
try {
return parseJsonParam(sessionStorage.getItem(getSessionListFilterKey(scope)))
} catch {
return null
}
}
const readListSortFromSession = (scope) => {
try {
const parsed = parseJsonParam(sessionStorage.getItem(getSessionListSortKey(scope)))
if (!parsed?.field || !parsed?.order) return null
return { field: parsed.field, order: parsed.order }
} catch {
return null
}
}
const writeListFilterToSession = (scope, filter) => {
const key = getSessionListFilterKey(scope)
if (Object.keys(filter).length > 0) {
sessionStorage.setItem(key, JSON.stringify(filter))
} else {
sessionStorage.removeItem(key)
}
}
const writeListSortToSession = (scope, sorter) => {
const key = getSessionListSortKey(scope)
if (sorter?.field && sorter?.order) {
sessionStorage.setItem(key, JSON.stringify(sorter))
} else {
sessionStorage.removeItem(key)
}
}
const readListViewFromSession = (scope) => {
try {
const parsed = parseJsonParam(sessionStorage.getItem(getSessionListViewKey(scope)))
if (!parsed?.id) return null
return {
id: String(parsed.id),
name: parsed.name || null
}
} catch {
return null
}
}
const writeListViewToSession = (scope, view) => {
const key = getSessionListViewKey(scope)
if (view?.id) {
sessionStorage.setItem(
key,
JSON.stringify({ id: String(view.id), name: view.name || null })
)
} 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 tabId = useContext(NavigationTabIdContext)
const { isElectron, getTabListState, setTabListState, activeTab } =
useNavigationTabs()
const [searchParams, setSearchParams] = useSearchParams()
const [pageFilters, setPageFilters] = useState({})
const [pageSorters, setPageSorters] = useState({})
// Effective list filters (view filter + user filters). Survives navigating
// from the list page to an info page so breadcrumb/neighbors keep working.
const [objectListFilters, setObjectListFilters] = useState({})
const [objectListSorters, setObjectListSorters] = useState({})
// Active list view (id + name) per object type — same survival pattern as filters.
const [objectListViews, setObjectListViews] = useState({})
const searchParamsRef = useRef(searchParams)
// Tracks an in-flight optimistic URL write so a render with stale router
// searchParams cannot clobber searchParamsRef and resurrect deleted params
// (e.g. viewId briefly cleared on All, then written back by persistTableState).
const pendingSearchParamsRef = useRef(null)
const previousSearchParamsRef = useRef(searchParams.toString())
useEffect(() => {
const current = searchParams.toString()
if (pendingSearchParamsRef.current != null) {
if (current === pendingSearchParamsRef.current) {
pendingSearchParamsRef.current = null
} else if (current === previousSearchParamsRef.current) {
// Stale snapshot from before our write — keep the optimistic ref.
return
} else {
// External navigation while a write was pending — accept it.
pendingSearchParamsRef.current = null
}
}
previousSearchParamsRef.current = current
searchParamsRef.current = searchParams
}, [searchParams])
// Keep sequential URL writes in the same tick from clobbering each other
// (e.g. persistView then persistTableState both calling setSearchParams).
const updateSearchParams = useCallback(
(mutator) => {
const previous = searchParamsRef.current.toString()
const next = new URLSearchParams(searchParamsRef.current)
mutator(next)
previousSearchParamsRef.current = previous
searchParamsRef.current = next
pendingSearchParamsRef.current = next.toString()
setSearchParams(next, { replace: true })
},
[setSearchParams]
)
const resolveScopedTabId = useCallback(
() => tabId || (isElectron ? activeTab?.id : null),
[activeTab?.id, isElectron, tabId]
)
const getPersistedFilter = useCallback(
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).filter || {}
}
if (useFilterInUrl) {
const fromUrl = readFilterFromUrl(searchParams)
if (fromUrl) return fromUrl
}
if (useFilterInSession) {
const fromSession = readFilterFromSession(scope)
if (fromSession) return fromSession
}
return {}
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const getPersistedSorter = useCallback(
(scope, { useSortInUrl = false, useSortInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).sort || {}
}
if (useSortInUrl) {
const fromUrl = readSortFromUrl(searchParams)
if (fromUrl) return fromUrl
}
if (useSortInSession) {
const fromSession = readSortFromSession(scope)
if (fromSession) return fromSession
}
return {}
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const getViewFromUrl = useCallback(
() => readViewFromUrl(searchParams),
[searchParams]
)
const getPersistedView = useCallback(
(scope) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
const fromUrl = readViewFromUrl(searchParams)
if (fromUrl) return { id: String(fromUrl), name: null }
return readListViewFromSession(scope)
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const persistView = useCallback(
(
viewId,
{ clearFilterSort = Boolean(viewId), scope, name } = {}
) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId && scope) {
setTabListState(scopedTabId, scope, {
viewId: viewId ? String(viewId) : null,
viewName: viewId ? name || null : null
})
return
}
updateSearchParams((next) => {
// Drop legacy `view` param that previously stored `_reference`
next.delete('view')
if (viewId) {
next.set(VIEW_URL_PARAM, String(viewId))
} else {
next.delete(VIEW_URL_PARAM)
}
// View filter/sort live on the view object — never in the URL
if (clearFilterSort) {
next.delete(FILTER_URL_PARAM)
next.delete(SORT_URL_PARAM)
}
})
},
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistFilter = useCallback(
(
scope,
filterState,
{ saveFilterInSession = false, saveFilterInUrl = false } = {}
) => {
const active = getActiveFilterValues(filterState)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveFilterInSession || saveFilterInUrl) {
setTabListState(scopedTabId, scope, { filter: active })
}
return
}
if (saveFilterInSession) {
writeFilterToSession(scope, active)
}
if (saveFilterInUrl) {
updateSearchParams((next) => {
if (Object.keys(active).length > 0) {
next.set(FILTER_URL_PARAM, JSON.stringify(active))
} else {
next.delete(FILTER_URL_PARAM)
}
})
}
},
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistSort = useCallback(
(
scope,
sorter,
{ saveSortInSession = false, saveSortInUrl = false } = {}
) => {
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveSortInSession || saveSortInUrl) {
setTabListState(scopedTabId, scope, { sort: nextSorter })
}
return
}
if (saveSortInSession) {
writeSortToSession(scope, nextSorter)
}
if (saveSortInUrl) {
updateSearchParams((next) => {
if (nextSorter.field && nextSorter.order) {
next.set(SORT_URL_PARAM, JSON.stringify(nextSorter))
} else {
next.delete(SORT_URL_PARAM)
}
})
}
},
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
const persistTableState = useCallback(
(
scope,
filterState,
sorter,
{
saveFilterInSession = false,
saveFilterInUrl = false,
saveSortInSession = false,
saveSortInUrl = false
} = {}
) => {
const active = getActiveFilterValues(filterState)
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const patch = {}
if (saveFilterInSession || saveFilterInUrl) {
patch.filter = active
}
if (saveSortInSession || saveSortInUrl) {
patch.sort = nextSorter
}
if (Object.keys(patch).length > 0) {
setTabListState(scopedTabId, scope, patch)
}
return
}
if (saveFilterInSession) {
writeFilterToSession(scope, active)
}
if (saveSortInSession) {
writeSortToSession(scope, nextSorter)
}
if (saveFilterInUrl || saveSortInUrl) {
updateSearchParams((next) => {
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)
}
}
})
}
},
[isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
)
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 setPageSorter = useCallback((path, sorter) => {
const hasSort = sorter?.field && sorter?.order
setPageSorters((prev) => {
if (!hasSort) {
if (!(path in prev)) return prev
const next = { ...prev }
delete next[path]
return next
}
return { ...prev, [path]: { field: sorter.field, order: sorter.order } }
})
}, [])
const setObjectListFilter = useCallback((objectType, filter) => {
if (!objectType) return
const active = getActiveFilterValues(filter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listFilter: active })
} else {
writeListFilterToSession(objectType, active)
}
setObjectListFilters((prev) => {
const hasActive = Object.keys(active).length > 0
if (!hasActive) {
if (!(objectType in prev)) return prev
const next = { ...prev }
delete next[objectType]
return next
}
return { ...prev, [objectType]: active }
})
}, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListSorter = useCallback((objectType, sorter) => {
if (!objectType) return
const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listSort: nextSorter })
} else {
writeListSortToSession(objectType, nextSorter)
}
setObjectListSorters((prev) => {
const hasSort = nextSorter?.field && nextSorter?.order
if (!hasSort) {
if (!(objectType in prev)) return prev
const next = { ...prev }
delete next[objectType]
return next
}
return { ...prev, [objectType]: nextSorter }
})
}, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListView = useCallback((objectType, view) => {
if (!objectType) return
const nextView =
view?.id != null
? { id: String(view.id), name: view.name || null }
: null
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, {
viewId: nextView?.id || null,
viewName: nextView?.name || null
})
} else {
writeListViewToSession(objectType, nextView)
}
setObjectListViews((prev) => {
if (!nextView) {
if (!(objectType in prev)) return prev
const next = { ...prev }
delete next[objectType]
return next
}
const existing = prev[objectType]
if (
existing &&
existing.id === nextView.id &&
existing.name === nextView.name
) {
return prev
}
return { ...prev, [objectType]: nextView }
})
}, [isElectron, resolveScopedTabId, setTabListState])
const getObjectListFilter = useCallback(
(objectType) => {
if (!objectType) return {}
if (objectListFilters[objectType]) {
return objectListFilters[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getActiveFilterValues(
getTabListState(scopedTabId, objectType).listFilter || {}
)
}
return getActiveFilterValues(readListFilterFromSession(objectType) || {})
},
[getTabListState, isElectron, objectListFilters, resolveScopedTabId]
)
const getObjectListSorter = useCallback(
(objectType) => {
if (!objectType) return {}
if (objectListSorters[objectType]) {
return objectListSorters[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, objectType).listSort || {}
}
return readListSortFromSession(objectType) || {}
},
[getTabListState, isElectron, objectListSorters, resolveScopedTabId]
)
const getObjectListView = useCallback(
(objectType) => {
if (!objectType) return null
if (objectListViews[objectType]) {
return objectListViews[objectType]
}
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, objectType)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
return readListViewFromSession(objectType)
},
[getTabListState, isElectron, objectListViews, resolveScopedTabId]
)
const hasPageFilter = useCallback(
(path) => Object.keys(pageFilters[path] || {}).length > 0,
[pageFilters]
)
const hasStoredFilter = useCallback((scope) => {
if (!scope) return false
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
if (entry.viewId) {
return Object.keys(entry.listFilter || {}).length > 0
}
return (
Object.keys(entry.filter || {}).length > 0 ||
Object.keys(entry.listFilter || {}).length > 0
)
}
// When a view is active, only report the effective list filter (which
// already accounts for the view). Don't fall back to the All-tab session
// filter — that belongs to a different context.
if (objectListViews[scope]) {
return Object.keys(objectListFilters[scope] || {}).length > 0
}
if (hasStoredSessionFilter(scope)) return true
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
}, [
getTabListState,
isElectron,
objectListFilters,
objectListViews,
resolveScopedTabId
])
const value = useMemo(
() => ({
getPersistedFilter,
getPersistedSorter,
getPersistedView,
getViewFromUrl,
persistView,
persistFilter,
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
setObjectListFilter,
setObjectListSorter,
setObjectListView,
getObjectListFilter,
getObjectListSorter,
getObjectListView,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters,
objectListFilters,
objectListSorters,
objectListViews
}),
[
getPersistedFilter,
getPersistedSorter,
getPersistedView,
getViewFromUrl,
persistView,
persistFilter,
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
setObjectListFilter,
setObjectListSorter,
setObjectListView,
getObjectListFilter,
getObjectListSorter,
getObjectListView,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters,
objectListFilters,
objectListSorters,
objectListViews
]
)
return (
<TableStateContext.Provider value={value}>
{children}
</TableStateContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTableState = () => {
const context = useContext(TableStateContext)
if (!context) {
throw new Error('useTableState must be used within a TableStateProvider')
}
return context
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTableStatePersistence = ({
scope,
viewId = null,
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,
setPageSorter,
setObjectListFilter,
setObjectListSorter
} = useTableState()
// View filter/sort belong to the view object — never read/write them via
// the shared filter/sort URL or session keys used by the "All" tab.
const isViewScope = Boolean(viewId)
const readOptions = useMemo(
() => ({
useFilterInUrl: isViewScope ? false : useFilterInUrl,
useFilterInSession: isViewScope ? false : useFilterInSession,
useSortInUrl: isViewScope ? false : useSortInUrl,
useSortInSession: isViewScope ? false : useSortInSession
}),
[
isViewScope,
useFilterInUrl,
useFilterInSession,
useSortInUrl,
useSortInSession
]
)
const saveOptions = useMemo(
() => ({
saveFilterInSession: isViewScope ? false : saveFilterInSession,
saveFilterInUrl: isViewScope ? false : saveFilterInUrl,
saveSortInSession: isViewScope ? false : saveSortInSession,
saveSortInUrl: isViewScope ? false : saveSortInUrl
}),
[
isViewScope,
saveFilterInSession,
saveFilterInUrl,
saveSortInSession,
saveSortInUrl
]
)
const allTabReadOptions = useMemo(
() => ({
useFilterInUrl,
useFilterInSession,
useSortInUrl,
useSortInSession
}),
[useFilterInUrl, useFilterInSession, useSortInUrl, useSortInSession]
)
const allTabSaveOptions = 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]
)
// Always reads the All-tab filter/sort, even while a view is still selected
// (used when switching back to All before listViewId has cleared).
const getAllTabPersistedFilter = useCallback(
() => getPersistedFilterCtx(scope, allTabReadOptions),
[getPersistedFilterCtx, scope, allTabReadOptions]
)
const getAllTabPersistedSorter = useCallback(
() => getPersistedSorterCtx(scope, allTabReadOptions),
[getPersistedSorterCtx, scope, allTabReadOptions]
)
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 persistAllTabTableState = useCallback(
(filterState, sorter) =>
persistTableStateCtx(scope, filterState, sorter, allTabSaveOptions),
[persistTableStateCtx, scope, allTabSaveOptions]
)
const registerPageFilter = useCallback(
(filterState) => {
if (pagePath) {
setPageFilter(pagePath, filterState)
}
},
[pagePath, setPageFilter]
)
const registerPageSorter = useCallback(
(sorter) => {
if (pagePath) {
setPageSorter(pagePath, sorter)
}
},
[pagePath, setPageSorter]
)
const registerObjectListFilter = useCallback(
(filterState) => {
if (scope) {
setObjectListFilter(scope, filterState)
}
},
[scope, setObjectListFilter]
)
const registerObjectListSorter = useCallback(
(sorter) => {
if (scope) {
setObjectListSorter(scope, sorter)
}
},
[scope, setObjectListSorter]
)
return {
persistenceScope: scope,
getPersistedFilter,
getPersistedSorter,
getAllTabPersistedFilter,
getAllTabPersistedSorter,
persistFilter,
persistSort,
persistTableState,
persistAllTabTableState,
registerPageFilter,
registerPageSorter,
registerObjectListFilter,
registerObjectListSorter,
getActiveFilterValues
}
}
TableStateProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default TableStateContext