From 9b344b6a3d6677e311c972bc6b4af03837fcf2c7 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Wed, 2 Sep 2026 22:56:51 +0100 Subject: [PATCH] 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. --- .../Dashboard/common/DashboardBreadcrumb.jsx | 14 ++- .../context/ObjectListViewContext.jsx | 49 +++++++-- .../Dashboard/context/TableStateContext.jsx | 102 +++++++++++++++++- 3 files changed, 149 insertions(+), 16 deletions(-) diff --git a/src/components/Dashboard/common/DashboardBreadcrumb.jsx b/src/components/Dashboard/common/DashboardBreadcrumb.jsx index ae39fa72..c9e5f393 100644 --- a/src/components/Dashboard/common/DashboardBreadcrumb.jsx +++ b/src/components/Dashboard/common/DashboardBreadcrumb.jsx @@ -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: ( - {name} + {displayName} ), key: segment @@ -71,7 +79,7 @@ const DashboardBreadcrumb = () => { gap: 6 }} > - {name} + {displayName} {showFilterIcon && } ), diff --git a/src/components/Dashboard/context/ObjectListViewContext.jsx b/src/components/Dashboard/context/ObjectListViewContext.jsx index 8695e48e..c693c500 100644 --- a/src/components/Dashboard/context/ObjectListViewContext.jsx +++ b/src/components/Dashboard/context/ObjectListViewContext.jsx @@ -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) diff --git a/src/components/Dashboard/context/TableStateContext.jsx b/src/components/Dashboard/context/TableStateContext.jsx index 4e5a9b94..39f6829b 100644 --- a/src/components/Dashboard/context/TableStateContext.jsx +++ b/src/components/Dashboard/context/TableStateContext.jsx @@ -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 ] )