import { createContext, useCallback, useContext, useEffect, useMemo, useRef, 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' 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 [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 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 getViewFromUrl = useCallback( () => readViewFromUrl(searchParams), [searchParams] ) const persistView = useCallback( (viewId, { clearFilterSort = Boolean(viewId) } = {}) => { 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) } }) }, [updateSearchParams] ) const persistFilter = useCallback( ( scope, filterState, { saveFilterInSession = false, saveFilterInUrl = false } = {} ) => { const active = getActiveFilterValues(filterState) 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) } }) } }, [updateSearchParams] ) const persistSort = useCallback( ( scope, sorter, { saveSortInSession = false, saveSortInUrl = false } = {} ) => { const nextSorter = normalizeSorter(sorter) 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) } }) } }, [updateSearchParams] ) 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) { 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) } } }) } }, [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) 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 } }) }, []) const setObjectListSorter = useCallback((objectType, sorter) => { if (!objectType) return const nextSorter = normalizeSorter(sorter) 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 } }) }, []) const setObjectListView = useCallback((objectType, view) => { if (!objectType) return const nextView = view?.id != null ? { id: String(view.id), name: view.name || null } : null 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 } }) }, []) const getObjectListFilter = useCallback( (objectType) => { if (!objectType) return {} if (objectListFilters[objectType]) { return objectListFilters[objectType] } return getActiveFilterValues(readListFilterFromSession(objectType) || {}) }, [objectListFilters] ) const getObjectListSorter = useCallback( (objectType) => { if (!objectType) return {} if (objectListSorters[objectType]) { return objectListSorters[objectType] } return readListSortFromSession(objectType) || {} }, [objectListSorters] ) const getObjectListView = useCallback( (objectType) => { if (!objectType) return null if (objectListViews[objectType]) { return objectListViews[objectType] } return readListViewFromSession(objectType) }, [objectListViews] ) const hasPageFilter = useCallback( (path) => Object.keys(pageFilters[path] || {}).length > 0, [pageFilters] ) const hasStoredFilter = useCallback((scope) => { if (!scope) return false if (hasStoredSessionFilter(scope)) return true if (Object.keys(objectListFilters[scope] || {}).length > 0) return true return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0 }, [objectListFilters]) const value = useMemo( () => ({ getPersistedFilter, getPersistedSorter, getViewFromUrl, persistView, persistFilter, persistSort, persistTableState, setPageFilter, setPageSorter, setObjectListFilter, setObjectListSorter, setObjectListView, getObjectListFilter, getObjectListSorter, getObjectListView, hasPageFilter, hasStoredFilter, pageFilters, pageSorters, objectListFilters, objectListSorters, objectListViews }), [ getPersistedFilter, getPersistedSorter, getViewFromUrl, persistView, persistFilter, persistSort, persistTableState, setPageFilter, setPageSorter, setObjectListFilter, setObjectListSorter, setObjectListView, getObjectListFilter, getObjectListSorter, getObjectListView, hasPageFilter, hasStoredFilter, pageFilters, pageSorters, objectListFilters, objectListSorters, objectListViews ] ) return ( {children} ) } // 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