Tom Butcher 73de7ac8ee Enhance Dashboard Components with Loading State and Styling Improvements
- Updated ListViewEditButtons to disable actions during initial loading, improving user experience.
- Refactored ListViewTabs to adjust icon styles based on loading state and removed unnecessary imports for cleaner code.
- Modified ObjectListViewContext to use viewId instead of view reference for better clarity and consistency.
- Updated TableStateContext to reflect changes in view parameter naming, enhancing code maintainability.
- Adjusted App.css for improved styling of segmented navigation items and loading indicators.
2026-09-02 21:26:39 +01:00

426 lines
11 KiB
JavaScript

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'
export const VIEW_URL_PARAM = 'viewId'
const getSessionFilterKey = (scope) => `tableState:${scope}:filter`
const getSessionSortKey = (scope) => `tableState:${scope}:sort`
// 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 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({})
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) => {
const next = new URLSearchParams(searchParams)
// 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)
}
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams]
)
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 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 hasPageFilter = useCallback(
(path) => Object.keys(pageFilters[path] || {}).length > 0,
[pageFilters]
)
const hasStoredFilter = useCallback((scope) => hasStoredSessionFilter(scope), [])
const value = useMemo(
() => ({
getPersistedFilter,
getPersistedSorter,
getViewFromUrl,
persistView,
persistFilter,
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters
}),
[
getPersistedFilter,
getPersistedSorter,
getViewFromUrl,
persistView,
persistFilter,
persistSort,
persistTableState,
setPageFilter,
setPageSorter,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters
]
)
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,
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
} = 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]
)
const registerPageSorter = useCallback(
(sorter) => {
if (pagePath) {
setPageSorter(pagePath, sorter)
}
},
[pagePath, setPageSorter]
)
return {
getPersistedFilter,
getPersistedSorter,
persistFilter,
persistSort,
persistTableState,
registerPageFilter,
registerPageSorter,
getActiveFilterValues
}
}
TableStateProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default TableStateContext