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

View File

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

View File

@ -2,6 +2,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
@ -19,6 +20,7 @@ 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) => {
@ -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) => {
if (sorter?.field && 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.
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)
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
// (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]
@ -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(
(objectType) => {
if (!objectType) return {}
@ -384,6 +463,17 @@ export const TableStateProvider = ({ children }) => {
[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]
@ -409,14 +499,17 @@ export const TableStateProvider = ({ children }) => {
setPageSorter,
setObjectListFilter,
setObjectListSorter,
setObjectListView,
getObjectListFilter,
getObjectListSorter,
getObjectListView,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters,
objectListFilters,
objectListSorters
objectListSorters,
objectListViews
}),
[
getPersistedFilter,
@ -430,14 +523,17 @@ export const TableStateProvider = ({ children }) => {
setPageSorter,
setObjectListFilter,
setObjectListSorter,
setObjectListView,
getObjectListFilter,
getObjectListSorter,
getObjectListView,
hasPageFilter,
hasStoredFilter,
pageFilters,
pageSorters,
objectListFilters,
objectListSorters
objectListSorters,
objectListViews
]
)