Enhance Dashboard Breadcrumb and ObjectListView Context with View Management

- Updated DashboardBreadcrumb to display a more informative name by incorporating the view name alongside the model label, improving user navigation clarity.
- Enhanced ObjectListViewProvider to synchronize the active view name with the dashboard breadcrumb, ensuring consistent view representation across components.
- Introduced session management for object list views in TableStateContext, allowing for persistent view states across user sessions.
This commit is contained in:
Tom Butcher 2026-09-02 22:56:51 +01:00
parent 9d1a032e10
commit 9b344b6a3d
3 changed files with 149 additions and 16 deletions

View File

@ -28,7 +28,7 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => { const DashboardBreadcrumb = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { hasPageFilter, hasStoredFilter } = useTableState() const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
const pathSnippets = location.pathname.split('/').filter((i) => i) const pathSnippets = location.pathname.split('/').filter((i) => i)
function segmentToModel(segment) { function segmentToModel(segment) {
@ -45,7 +45,15 @@ const DashboardBreadcrumb = () => {
const isMainSection = mainSections.includes(segment) const isMainSection = mainSections.includes(segment)
const model = segmentToModel(segment) const model = segmentToModel(segment)
const modelLabelPlural = model?.labelPlural ? model.labelPlural : null const modelLabelPlural = model?.labelPlural ? model.labelPlural : null
const viewName =
modelLabelPlural && model?.name && model.name !== 'unknown'
? getObjectListView(model.name)?.name
: null
const name = breadcrumbNameMap[segment] || modelLabelPlural || segment const name = breadcrumbNameMap[segment] || modelLabelPlural || segment
const displayName =
modelLabelPlural && viewName
? `${modelLabelPlural} - ${viewName}`
: name
const showFilterIcon = const showFilterIcon =
hasPageFilter(url) || hasPageFilter(url) ||
(model?.name && model.name !== 'unknown' && hasStoredFilter(model.name)) (model?.name && model.name !== 'unknown' && hasStoredFilter(model.name))
@ -53,7 +61,7 @@ const DashboardBreadcrumb = () => {
return { return {
title: ( title: (
<span style={{ padding: '0 12px' }} key={segment}> <span style={{ padding: '0 12px' }} key={segment}>
{name} {displayName}
</span> </span>
), ),
key: segment key: segment
@ -71,7 +79,7 @@ const DashboardBreadcrumb = () => {
gap: 6 gap: 6
}} }}
> >
{name} {displayName}
{showFilterIcon && <FilterIcon style={{ fontSize: 12 }} />} {showFilterIcon && <FilterIcon style={{ fontSize: 12 }} />}
</Link> </Link>
), ),

View File

@ -127,7 +127,7 @@ export const ObjectListViewProvider = ({
subscribeToObjectUpdates subscribeToObjectUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext) const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView } = useTableState() const { getViewFromUrl, persistView, setObjectListView } = useTableState()
const [views, setViews] = useState( const [views, setViews] = useState(
() => readCachedViews(objectType) || [] () => readCachedViews(objectType) || []
@ -187,6 +187,16 @@ export const ObjectListViewProvider = ({
viewsStateRef.current = { views, draftViews, isEditing } viewsStateRef.current = { views, draftViews, isEditing }
activeViewRef.current = activeView activeViewRef.current = activeView
// Keep the remembered view name in sync (incl. renames while editing) for
// consumers outside this provider (e.g. the dashboard breadcrumb).
useEffect(() => {
if (!objectType || !activeView) return
setObjectListView(objectType, {
id: String(activeView._id),
name: activeView.name || null
})
}, [objectType, activeView, setObjectListView])
const applyViewToTable = useCallback( const applyViewToTable = useCallback(
(tabKey) => { (tabKey) => {
if (!tableRef?.current?.applyViewState) return if (!tableRef?.current?.applyViewState) return
@ -253,12 +263,26 @@ export const ObjectListViewProvider = ({
loadViewsRef.current = loadViews loadViewsRef.current = loadViews
const rememberActiveView = useCallback( const rememberActiveView = useCallback(
(viewId) => { (view) => {
const key = viewId ? String(viewId) : null const key =
view == null
? null
: typeof view === 'string' || typeof view === 'number'
? String(view)
: view?._id != null
? String(view._id)
: null
const name =
key && view && typeof view === 'object' ? view.name || null : null
persistView(key) persistView(key)
writeLastViewId(objectType, key) writeLastViewId(objectType, key)
setObjectListView(
objectType,
key ? { id: key, name } : null
)
}, },
[objectType, persistView] [objectType, persistView, setObjectListView]
) )
useEffect(() => { useEffect(() => {
@ -327,6 +351,10 @@ export const ObjectListViewProvider = ({
const matchedId = String(matched._id) const matchedId = String(matched._id)
setActiveTabKey(matchedId) setActiveTabKey(matchedId)
writeLastViewId(objectType, matchedId) writeLastViewId(objectType, matchedId)
setObjectListView(objectType, {
id: matchedId,
name: matched.name || null
})
if (!matched._isDraft) { if (!matched._isDraft) {
// Keep viewId in the URL and strip any leftover filter/sort params // Keep viewId in the URL and strip any leftover filter/sort params
persistView(matchedId) persistView(matchedId)
@ -354,7 +382,8 @@ export const ObjectListViewProvider = ({
objectType, objectType,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
rememberActiveView rememberActiveView,
setObjectListView
]) ])
const selectTab = useCallback( const selectTab = useCallback(
@ -373,7 +402,7 @@ export const ObjectListViewProvider = ({
) )
if (!view || view._isDraft) return if (!view || view._isDraft) return
rememberActiveView(getObjectViewKey(view)) rememberActiveView(view)
}, },
[activeTabKey, draftViews, isEditing, rememberActiveView, views] [activeTabKey, draftViews, isEditing, rememberActiveView, views]
) )
@ -483,7 +512,7 @@ export const ObjectListViewProvider = ({
if (resolvedTabKey !== ALL_TAB_KEY && savedView && !savedView._isDraft) { if (resolvedTabKey !== ALL_TAB_KEY && savedView && !savedView._isDraft) {
setActiveTabKey(resolvedTabKey) setActiveTabKey(resolvedTabKey)
rememberActiveView(getObjectViewKey(savedView)) rememberActiveView(savedView)
} else if (resolvedTabKey !== ALL_TAB_KEY && !savedView) { } else if (resolvedTabKey !== ALL_TAB_KEY && !savedView) {
resolvedTabKey = ALL_TAB_KEY resolvedTabKey = ALL_TAB_KEY
setActiveTabKey(ALL_TAB_KEY) setActiveTabKey(ALL_TAB_KEY)
@ -535,7 +564,7 @@ export const ObjectListViewProvider = ({
const newKey = String(created._id) const newKey = String(created._id)
setActiveTabKey(newKey) setActiveTabKey(newKey)
applyViewToTable(newKey) applyViewToTable(newKey)
rememberActiveView(getObjectViewKey(created)) rememberActiveView(created)
} else { } else {
message.error('Failed to create view') message.error('Failed to create view')
} }
@ -571,7 +600,7 @@ export const ObjectListViewProvider = ({
(item) => String(item._id) === String(previousKey) (item) => String(item._id) === String(previousKey)
) )
if (view && !view._isDraft) { if (view && !view._isDraft) {
rememberActiveView(getObjectViewKey(view)) rememberActiveView(view)
} }
} }
applyViewToTable(previousKey) applyViewToTable(previousKey)
@ -665,7 +694,7 @@ export const ObjectListViewProvider = ({
(item) => String(item._id) === String(previousKey) (item) => String(item._id) === String(previousKey)
) )
if (view && !view._isDraft) { if (view && !view._isDraft) {
rememberActiveView(getObjectViewKey(view)) rememberActiveView(view)
} }
} }
applyViewToTable(previousKey) applyViewToTable(previousKey)

View File

@ -2,6 +2,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState useState
@ -19,6 +20,7 @@ const getSessionFilterKey = (scope) => `tableState:${scope}:filter`
const getSessionSortKey = (scope) => `tableState:${scope}:sort` const getSessionSortKey = (scope) => `tableState:${scope}:sort`
const getSessionListFilterKey = (scope) => `tableState:${scope}:listFilter` const getSessionListFilterKey = (scope) => `tableState:${scope}:listFilter`
const getSessionListSortKey = (scope) => `tableState:${scope}:listSort` const getSessionListSortKey = (scope) => `tableState:${scope}:listSort`
const getSessionListViewKey = (scope) => `tableState:${scope}:listView`
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const getActiveFilterValues = (filterState) => { export const getActiveFilterValues = (filterState) => {
@ -128,6 +130,31 @@ const writeListSortToSession = (scope, sorter) => {
} }
} }
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) => { const normalizeSorter = (sorter) => {
if (sorter?.field && sorter?.order) { if (sorter?.field && sorter?.order) {
return { field: sorter.field, order: sorter.order } return { field: sorter.field, order: sorter.order }
@ -143,16 +170,42 @@ export const TableStateProvider = ({ children }) => {
// from the list page to an info page so breadcrumb/neighbors keep working. // from the list page to an info page so breadcrumb/neighbors keep working.
const [objectListFilters, setObjectListFilters] = useState({}) const [objectListFilters, setObjectListFilters] = useState({})
const [objectListSorters, setObjectListSorters] = 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) const searchParamsRef = useRef(searchParams)
searchParamsRef.current = 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 // Keep sequential URL writes in the same tick from clobbering each other
// (e.g. persistView then persistTableState both calling setSearchParams). // (e.g. persistView then persistTableState both calling setSearchParams).
const updateSearchParams = useCallback( const updateSearchParams = useCallback(
(mutator) => { (mutator) => {
const previous = searchParamsRef.current.toString()
const next = new URLSearchParams(searchParamsRef.current) const next = new URLSearchParams(searchParamsRef.current)
mutator(next) mutator(next)
previousSearchParamsRef.current = previous
searchParamsRef.current = next searchParamsRef.current = next
pendingSearchParamsRef.current = next.toString()
setSearchParams(next, { replace: true }) setSearchParams(next, { replace: true })
}, },
[setSearchParams] [setSearchParams]
@ -362,6 +415,32 @@ export const TableStateProvider = ({ children }) => {
}) })
}, []) }, [])
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( const getObjectListFilter = useCallback(
(objectType) => { (objectType) => {
if (!objectType) return {} if (!objectType) return {}
@ -384,6 +463,17 @@ export const TableStateProvider = ({ children }) => {
[objectListSorters] [objectListSorters]
) )
const getObjectListView = useCallback(
(objectType) => {
if (!objectType) return null
if (objectListViews[objectType]) {
return objectListViews[objectType]
}
return readListViewFromSession(objectType)
},
[objectListViews]
)
const hasPageFilter = useCallback( const hasPageFilter = useCallback(
(path) => Object.keys(pageFilters[path] || {}).length > 0, (path) => Object.keys(pageFilters[path] || {}).length > 0,
[pageFilters] [pageFilters]
@ -409,14 +499,17 @@ export const TableStateProvider = ({ children }) => {
setPageSorter, setPageSorter,
setObjectListFilter, setObjectListFilter,
setObjectListSorter, setObjectListSorter,
setObjectListView,
getObjectListFilter, getObjectListFilter,
getObjectListSorter, getObjectListSorter,
getObjectListView,
hasPageFilter, hasPageFilter,
hasStoredFilter, hasStoredFilter,
pageFilters, pageFilters,
pageSorters, pageSorters,
objectListFilters, objectListFilters,
objectListSorters objectListSorters,
objectListViews
}), }),
[ [
getPersistedFilter, getPersistedFilter,
@ -430,14 +523,17 @@ export const TableStateProvider = ({ children }) => {
setPageSorter, setPageSorter,
setObjectListFilter, setObjectListFilter,
setObjectListSorter, setObjectListSorter,
setObjectListView,
getObjectListFilter, getObjectListFilter,
getObjectListSorter, getObjectListSorter,
getObjectListView,
hasPageFilter, hasPageFilter,
hasStoredFilter, hasStoredFilter,
pageFilters, pageFilters,
pageSorters, pageSorters,
objectListFilters, objectListFilters,
objectListSorters objectListSorters,
objectListViews
] ]
) )