Enhance Navigation Context and Table State Management

- Introduced NavigationTabIdContext to improve tab identification and management within the Dashboard.
- Updated TableStateProvider to utilize the new context for better handling of tab-specific state, particularly in Electron environments.
- Enhanced ObjectListViewProvider to integrate persisted view handling based on the new context, improving user experience across different navigation scenarios.
- Refactored various components to streamline state management and improve overall performance.
This commit is contained in:
Tom Butcher 2026-09-21 00:05:14 +01:00
parent 44c8729787
commit d348d6f2b3
6 changed files with 468 additions and 113 deletions

View File

@ -5,6 +5,7 @@ import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-route
import { TableStateProvider } from '../context/TableStateContext' import { TableStateProvider } from '../context/TableStateContext'
import { import {
NavigationTabActiveContext, NavigationTabActiveContext,
NavigationTabIdContext,
createNavigationTabActiveStore, createNavigationTabActiveStore,
useNavigationTabs useNavigationTabs
} from '../context/NavigationTabsContext' } from '../context/NavigationTabsContext'
@ -129,9 +130,11 @@ const DashboardTabPane = memo(function DashboardTabPane({
aria-hidden={!isActive} aria-hidden={!isActive}
inert={!isActive} inert={!isActive}
> >
<NavigationTabActiveContext.Provider value={storeRef.current}> <NavigationTabIdContext.Provider value={tabId}>
<DashboardTabPaneRoutes loc={loc} /> <NavigationTabActiveContext.Provider value={storeRef.current}>
</NavigationTabActiveContext.Provider> <DashboardTabPaneRoutes loc={loc} />
</NavigationTabActiveContext.Provider>
</NavigationTabIdContext.Provider>
</div> </div>
) )
}, arePanePropsEqual) }, arePanePropsEqual)

View File

@ -1778,7 +1778,8 @@ const ObjectTable = memo(
} }
} }
} else { } else {
// All tab: persist personal filter/sort to URL/session // All tab: persist personal filter/sort (session/URL, or this
// Electron tab's listState JSON when isElectron).
persistTableState(next, nextSorter) persistTableState(next, nextSorter)
} }
const effective = buildEffectiveFilter(next) const effective = buildEffectiveFilter(next)

View File

@ -20,6 +20,8 @@ const NavigationTabMetaContext = createContext({
setTabPage: () => {}, setTabPage: () => {},
isElectron: false isElectron: false
}) })
// eslint-disable-next-line react-refresh/only-export-components
export const NavigationTabIdContext = createContext(null)
const NavigationTabPreviewsContext = createContext({ const NavigationTabPreviewsContext = createContext({
tabPreviews: {}, tabPreviews: {},
tabPreviewCapturing: {} tabPreviewCapturing: {}
@ -83,13 +85,128 @@ const entryToPath = (entry) => {
const entriesEqual = (left, right) => entryToPath(left) === entryToPath(right) const entriesEqual = (left, right) => entryToPath(left) === entryToPath(right)
const EMPTY_LIST_STATE_ENTRY = {
viewId: null,
viewName: null,
filter: {},
sort: {},
listFilter: {},
listSort: {}
}
const parseSearchJson = (raw) => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
const normalizeSorterValue = (sorter) =>
sorter?.field && sorter?.order
? { field: sorter.field, order: sorter.order }
: {}
const normalizeListStateEntry = (value) => {
if (!value || typeof value !== 'object') {
return { ...EMPTY_LIST_STATE_ENTRY }
}
return {
viewId: value.viewId ? String(value.viewId) : null,
viewName: value.viewName || null,
filter:
value.filter && typeof value.filter === 'object' ? value.filter : {},
sort: normalizeSorterValue(value.sort),
listFilter:
value.listFilter && typeof value.listFilter === 'object'
? value.listFilter
: {},
listSort: normalizeSorterValue(value.listSort)
}
}
const normalizeListState = (listState) => {
if (!listState || typeof listState !== 'object' || Array.isArray(listState)) {
return {}
}
return Object.fromEntries(
Object.entries(listState).map(([scope, value]) => [
scope,
normalizeListStateEntry(value)
])
)
}
const cloneListState = (listState) =>
JSON.parse(JSON.stringify(normalizeListState(listState)))
const getTabListStateEntry = (tab, scope) => {
if (!scope) return { ...EMPTY_LIST_STATE_ENTRY }
return tab?.listState?.[scope]
? normalizeListStateEntry(tab.listState[scope])
: { ...EMPTY_LIST_STATE_ENTRY }
}
const listStateEntriesEqual = (left, right) =>
JSON.stringify(normalizeListStateEntry(left)) ===
JSON.stringify(normalizeListStateEntry(right))
const applyListStateToTab = (tab, scope, patch = {}) => {
if (!tab || !scope) return tab
const current = getTabListStateEntry(tab, scope)
const next = normalizeListStateEntry({
...current,
...patch,
viewId: patch.viewId !== undefined ? patch.viewId : current.viewId,
viewName: patch.viewName !== undefined ? patch.viewName : current.viewName,
filter: patch.filter !== undefined ? patch.filter : current.filter,
sort: patch.sort !== undefined ? patch.sort : current.sort,
listFilter:
patch.listFilter !== undefined ? patch.listFilter : current.listFilter,
listSort: patch.listSort !== undefined ? patch.listSort : current.listSort
})
if (listStateEntriesEqual(current, next) && tab.listState?.[scope]) {
return tab
}
return {
...tab,
listState: {
...(tab.listState || {}),
[scope]: next
}
}
}
const seedListStateFromHistory = (tab) => {
if (!tab?.modelName) return normalizeListState(tab?.listState)
if (tab.listState?.[tab.modelName]) {
return normalizeListState(tab.listState)
}
const entry = getTabCurrentEntry(tab)
const params = new URLSearchParams(entry?.search || '')
const viewId = params.get('viewId')
const filter = parseSearchJson(params.get('filter'))
const sort = parseSearchJson(params.get('sort'))
if (!viewId && !filter && !sort?.field) {
return normalizeListState(tab.listState)
}
return applyListStateToTab(tab, tab.modelName, {
viewId,
filter: filter || {},
sort: sort || {}
}).listState
}
const createTabFromLocation = (location, extras = {}) => ({ const createTabFromLocation = (location, extras = {}) => ({
id: createTabId(), id: createTabId(),
title: extras.title || 'Farm Control', title: extras.title || 'Farm Control',
modelName: extras.modelName || null, modelName: extras.modelName || null,
iconKey: extras.iconKey || null, iconKey: extras.iconKey || null,
history: [locationToEntry(location)], history: [locationToEntry(location)],
historyIndex: 0 historyIndex: 0,
listState: cloneListState(extras.listState)
}) })
const cloneCurrentPageTab = (tab, location) => ({ const cloneCurrentPageTab = (tab, location) => ({
@ -98,7 +215,8 @@ const cloneCurrentPageTab = (tab, location) => ({
modelName: tab?.modelName || null, modelName: tab?.modelName || null,
iconKey: tab?.iconKey || null, iconKey: tab?.iconKey || null,
history: [locationToEntry(location)], history: [locationToEntry(location)],
historyIndex: 0 historyIndex: 0,
listState: cloneListState(tab?.listState)
}) })
const getTabCurrentEntry = (tab) => const getTabCurrentEntry = (tab) =>
@ -117,13 +235,18 @@ const normalizeTab = (tab, fallbackLocation) => {
const rawIndex = Number.isInteger(tab?.historyIndex) const rawIndex = Number.isInteger(tab?.historyIndex)
? tab.historyIndex ? tab.historyIndex
: history.length - 1 : history.length - 1
return { const normalized = {
id: tab?.id || createTabId(), id: tab?.id || createTabId(),
title: tab?.title || 'Farm Control', title: tab?.title || 'Farm Control',
modelName: tab?.modelName || null, modelName: tab?.modelName || null,
iconKey: tab?.iconKey || null, iconKey: tab?.iconKey || null,
history, history,
historyIndex: Math.min(Math.max(rawIndex, 0), history.length - 1) historyIndex: Math.min(Math.max(rawIndex, 0), history.length - 1),
listState: normalizeListState(tab?.listState)
}
return {
...normalized,
listState: seedListStateFromHistory(normalized)
} }
} }
@ -205,19 +328,38 @@ export const NavigationTabsProvider = ({ children }) => {
const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light') const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light')
const tabStripRootRef = useRef(null) const tabStripRootRef = useRef(null)
const scrollTabStripToEndIntervalRef = useRef(null) const scrollTabStripToEndIntervalRef = useRef(null)
// Late URL writes from the tab we just left (e.g. persistView) must not
// attach to the newly selected tab.
const foreignLocationRef = useRef(null)
tabsRef.current = tabs tabsRef.current = tabs
activeTabIdRef.current = activeTabId activeTabIdRef.current = activeTabId
locationRef.current = location locationRef.current = location
previewThemeRef.current = isDarkMode ? 'dark' : 'light' previewThemeRef.current = isDarkMode ? 'dark' : 'light'
const isRestoreLocation = useCallback((entry) => { const markForeignLocation = useCallback((previousTabId) => {
const restoring = isRestoringRef.current if (!previousTabId) {
if (!restoring) return false foreignLocationRef.current = null
if (restoring === true) return true return
return ( }
entriesEqual(restoring.target, entry) || foreignLocationRef.current = {
entriesEqual(restoring.from, entry) tabId: previousTabId,
) entry: locationToEntry(locationRef.current),
expiresAt: Date.now() + 250
}
}, [])
const applyEntryToTabId = useCallback((tabId, entry) => {
if (!tabId || !entry) return
setTabs((current) => {
let changed = false
const next = current.map((tab) => {
if (tab.id !== tabId) return tab
const updated = applyLocationToTab(tab, entry)
if (updated !== tab) changed = true
return updated
})
return changed ? next : current
})
}, []) }, [])
const shouldIgnoreRestoredLocation = useCallback((entry) => { const shouldIgnoreRestoredLocation = useCallback((entry) => {
@ -239,11 +381,15 @@ export const NavigationTabsProvider = ({ children }) => {
}, []) }, [])
const restoreToEntry = useCallback( const restoreToEntry = useCallback(
(entry) => { (entry, { previousTabId } = {}) => {
if (!entry) return if (!entry) return
isRestoringRef.current = { isRestoringRef.current = {
target: locationToEntry(entry), target: locationToEntry(entry),
from: locationToEntry(locationRef.current) from: locationToEntry(locationRef.current),
previousTabId:
previousTabId === undefined
? activeTabIdRef.current
: previousTabId
} }
navigate(entryToPath(entry), { replace: true }) navigate(entryToPath(entry), { replace: true })
}, },
@ -256,16 +402,19 @@ export const NavigationTabsProvider = ({ children }) => {
const nextTab = tabsRef.current.find((tab) => tab.id === tabId) const nextTab = tabsRef.current.find((tab) => tab.id === tabId)
if (!nextTab) return if (!nextTab) return
const previousTabId = activeTabIdRef.current
const nextEntry = getTabCurrentEntry(nextTab) const nextEntry = getTabCurrentEntry(nextTab)
markForeignLocation(previousTabId)
setActiveTabId(tabId) setActiveTabId(tabId)
activeTabIdRef.current = tabId
if (entriesEqual(nextEntry, locationRef.current)) { if (entriesEqual(nextEntry, locationRef.current)) {
return return
} }
restoreToEntry(nextEntry) restoreToEntry(nextEntry, { previousTabId })
}, },
[restoreToEntry] [markForeignLocation, restoreToEntry]
) )
const registerTabStripRoot = useCallback((element) => { const registerTabStripRoot = useCallback((element) => {
@ -325,17 +474,19 @@ export const NavigationTabsProvider = ({ children }) => {
? createTabFromLocation(entry) ? createTabFromLocation(entry)
: cloneCurrentPageTab(currentTab, location) : cloneCurrentPageTab(currentTab, location)
const previousTabId = activeTabIdRef.current
setTabs((current) => [...current, nextTab]) setTabs((current) => [...current, nextTab])
markForeignLocation(previousTabId)
setActiveTabId(nextTab.id) setActiveTabId(nextTab.id)
activeTabIdRef.current = nextTab.id activeTabIdRef.current = nextTab.id
if (targetUrl && !entriesEqual(entry, locationRef.current)) { if (targetUrl && !entriesEqual(entry, locationRef.current)) {
restoreToEntry(entry) restoreToEntry(entry, { previousTabId })
} }
scrollTabStripToEnd() scrollTabStripToEnd()
}, },
[location, restoreToEntry, scrollTabStripToEnd] [location, markForeignLocation, restoreToEntry, scrollTabStripToEnd]
) )
const removeTab = useCallback( const removeTab = useCallback(
@ -371,15 +522,18 @@ export const NavigationTabsProvider = ({ children }) => {
return nextCapturing return nextCapturing
}) })
if (nextActive) { if (nextActive) {
const previousTabId = activeTabIdRef.current
markForeignLocation(previousTabId)
setActiveTabId(nextActive.id) setActiveTabId(nextActive.id)
activeTabIdRef.current = nextActive.id
const nextEntry = getTabCurrentEntry(nextActive) const nextEntry = getTabCurrentEntry(nextActive)
if (!entriesEqual(nextEntry, locationRef.current)) { if (!entriesEqual(nextEntry, locationRef.current)) {
restoreToEntry(nextEntry) restoreToEntry(nextEntry, { previousTabId })
} }
} }
return true return true
}, },
[handleWindowControl, restoreToEntry] [handleWindowControl, markForeignLocation, restoreToEntry]
) )
const closeTab = useCallback( const closeTab = useCallback(
@ -409,13 +563,16 @@ export const NavigationTabsProvider = ({ children }) => {
next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, tab) next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, tab)
return next return next
}) })
const previousTabId = activeTabIdRef.current
markForeignLocation(previousTabId)
setActiveTabId(tab.id) setActiveTabId(tab.id)
activeTabIdRef.current = tab.id
const nextEntry = getTabCurrentEntry(tab) const nextEntry = getTabCurrentEntry(tab)
if (!entriesEqual(nextEntry, locationRef.current)) { if (!entriesEqual(nextEntry, locationRef.current)) {
restoreToEntry(nextEntry) restoreToEntry(nextEntry, { previousTabId })
} }
}, },
[restoreToEntry] [markForeignLocation, restoreToEntry]
) )
const handleTabDragStart = useCallback( const handleTabDragStart = useCallback(
@ -549,31 +706,45 @@ export const NavigationTabsProvider = ({ children }) => {
}, []) }, [])
const setTabPage = useCallback( const setTabPage = useCallback(
({ title, modelName, iconKey, location: pageLocation } = {}) => { ({ title, modelName, iconKey } = {}) => {
const activeId = activeTabIdRef.current const activeId = activeTabIdRef.current
if (!activeId) return if (!activeId) return
const pageEntry = pageLocation ? locationToEntry(pageLocation) : null
setTabs((current) => { setTabs((current) => {
if (current.length === 0) return current if (current.length === 0) return current
let changed = false let changed = false
const next = current.map((tab) => { const next = current.map((tab) => {
if (tab.id !== activeId) return tab if (tab.id !== activeId) return tab
let updated = applyPageMetaToTab(tab, { title, modelName, iconKey }) const updated = applyPageMetaToTab(tab, { title, modelName, iconKey })
if (pageEntry && !isRestoreLocation(pageEntry)) {
updated = applyLocationToTab(updated, pageEntry)
}
if (updated !== tab) changed = true if (updated !== tab) changed = true
return updated return updated
}) })
return changed ? next : current return changed ? next : current
}) })
}, },
[isRestoreLocation] []
) )
const getTabListState = useCallback((tabId, scope) => {
const tab = tabsRef.current.find((item) => item.id === tabId)
return getTabListStateEntry(tab, scope)
}, [])
const setTabListState = useCallback((tabId, scope, patch) => {
if (!tabId || !scope) return
setTabs((current) => {
let changed = false
const next = current.map((tab) => {
if (tab.id !== tabId) return tab
const updated = applyListStateToTab(tab, scope, patch)
if (updated !== tab) changed = true
return updated
})
return changed ? next : current
})
}, [])
const goBack = useCallback(() => { const goBack = useCallback(() => {
const currentTab = tabsRef.current.find( const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current (tab) => tab.id === activeTabIdRef.current
@ -676,8 +847,47 @@ export const NavigationTabsProvider = ({ children }) => {
if (!isElectron || !hydrated) return if (!isElectron || !hydrated) return
const entry = locationToEntry(location) const entry = locationToEntry(location)
const restoring = isRestoringRef.current
const foreign = foreignLocationRef.current
if (restoring && restoring !== true) {
if (entriesEqual(restoring.target, entry)) {
isRestoringRef.current = null
return
}
if (entriesEqual(restoring.from, entry)) {
return
}
// A delayed write from the previous tab (viewId, filters, etc.)
if (restoring.previousTabId) {
applyEntryToTabId(restoring.previousTabId, entry)
if (foreign) foreign.entry = entry
}
return
}
if (shouldIgnoreRestoredLocation(entry)) return if (shouldIgnoreRestoredLocation(entry)) return
if (
foreign &&
foreign.tabId &&
foreign.tabId !== activeTabIdRef.current &&
Date.now() < foreign.expiresAt
) {
if (entriesEqual(entry, foreign.entry)) {
return
}
if (entry.pathname === foreign.entry.pathname) {
applyEntryToTabId(foreign.tabId, entry)
foreign.entry = entry
return
}
}
if (foreign && Date.now() >= foreign.expiresAt) {
foreignLocationRef.current = null
}
setTabs((current) => { setTabs((current) => {
if (current.length === 0) { if (current.length === 0) {
const initialTab = createTabFromLocation(location) const initialTab = createTabFromLocation(location)
@ -694,7 +904,13 @@ export const NavigationTabsProvider = ({ children }) => {
}) })
return changed ? next : current return changed ? next : current
}) })
}, [hydrated, isElectron, location, shouldIgnoreRestoredLocation]) }, [
applyEntryToTabId,
hydrated,
isElectron,
location,
shouldIgnoreRestoredLocation
])
useEffect(() => { useEffect(() => {
if (!isElectron || !hydrated || !syncWindowTabs) return undefined if (!isElectron || !hydrated || !syncWindowTabs) return undefined
@ -768,7 +984,9 @@ export const NavigationTabsProvider = ({ children }) => {
createNewWindow, createNewWindow,
registerTabPane, registerTabPane,
registerTabStripRoot, registerTabStripRoot,
captureTabPreview captureTabPreview,
getTabListState,
setTabListState
}), }),
[ [
activeTab, activeTab,
@ -792,6 +1010,8 @@ export const NavigationTabsProvider = ({ children }) => {
reorderTabs, reorderTabs,
selectTab, selectTab,
setTabPage, setTabPage,
getTabListState,
setTabListState,
tabs tabs
] ]
) )
@ -850,19 +1070,18 @@ export const useTabPreview = (tabId) => {
export const useNavigationTabPage = ({ title, modelName, iconKey } = {}) => { export const useNavigationTabPage = ({ title, modelName, iconKey } = {}) => {
const { setTabPage, isElectron } = useContext(NavigationTabMetaContext) const { setTabPage, isElectron } = useContext(NavigationTabMetaContext)
const store = useContext(NavigationTabActiveContext) const store = useContext(NavigationTabActiveContext)
const pageLocation = useLocation()
useEffect(() => { useEffect(() => {
if (!isElectron || !title) return undefined if (!isElectron || !title) return undefined
const sync = () => { const sync = () => {
if (!store.getSnapshot()) return if (!store.getSnapshot()) return
setTabPage({ title, modelName, iconKey, location: pageLocation }) setTabPage({ title, modelName, iconKey })
} }
sync() sync()
return store.subscribe(sync) return store.subscribe(sync)
}, [iconKey, isElectron, modelName, pageLocation, setTabPage, store, title]) }, [iconKey, isElectron, modelName, setTabPage, store, title])
} }
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components

View File

@ -15,7 +15,10 @@ import { useTableState } from './TableStateContext'
import useViewMode from '../hooks/useViewMode' import useViewMode from '../hooks/useViewMode'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils' import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { useNavigationTabPage } from './NavigationTabsContext' import {
useNavigationTabPage,
useNavigationTabs
} from './NavigationTabsContext'
const ObjectListViewContext = createContext() const ObjectListViewContext = createContext()
@ -128,14 +131,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
subscribeToObjectUpdates subscribeToObjectUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext) const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView, setObjectListView } = useTableState() const { getViewFromUrl, getPersistedView, persistView, setObjectListView } =
useTableState()
const { isElectron } = useNavigationTabs()
const listModel = getModelByName(objectType) const listModel = getModelByName(objectType)
useNavigationTabPage({
title: listModel?.labelPlural
? `List - ${listModel.labelPlural}`
: 'List',
modelName: objectType
})
const [views, setViews] = useState(() => readCachedViews(objectType) || []) const [views, setViews] = useState(() => readCachedViews(objectType) || [])
const [draftViews, setDraftViews] = useState(() => { const [draftViews, setDraftViews] = useState(() => {
@ -203,6 +202,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
return view return view
}, [activeTabKey, displayedViews]) }, [activeTabKey, displayedViews])
const listLabel = listModel?.labelPlural || 'List'
const viewName = activeView?.name
useNavigationTabPage({
title: viewName ? `${listLabel} - ${viewName}` : listLabel,
modelName: objectType
})
viewsStateRef.current = { views, draftViews, isEditing } viewsStateRef.current = { views, draftViews, isEditing }
activeViewRef.current = activeView activeViewRef.current = activeView
@ -297,11 +303,13 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
const name = const name =
key && view && typeof view === 'object' ? view.name || null : null key && view && typeof view === 'object' ? view.name || null : null
persistView(key) persistView(key, { scope: objectType, name })
writeLastViewId(objectType, key) if (!isElectron) {
writeLastViewId(objectType, key)
}
setObjectListView(objectType, key ? { id: key, name } : null) setObjectListView(objectType, key ? { id: key, name } : null)
}, },
[objectType, persistView, setObjectListView] [isElectron, objectType, persistView, setObjectListView]
) )
useEffect(() => { useEffect(() => {
@ -353,9 +361,10 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// Allow early restore from cache; only block when we have nothing to match against yet // Allow early restore from cache; only block when we have nothing to match against yet
if (initialLoading && views.length === 0) return if (initialLoading && views.length === 0) return
const urlViewId = getViewFromUrl() const persistedViewId = getPersistedView(objectType)?.id || null
const rememberedViewId = readLastViewId(objectType) const urlViewId = isElectron ? null : getViewFromUrl()
const candidateViewId = urlViewId || rememberedViewId const rememberedViewId = isElectron ? null : readLastViewId(objectType)
const candidateViewId = persistedViewId || urlViewId || rememberedViewId
if (!candidateViewId) { if (!candidateViewId) {
urlSyncedRef.current = true urlSyncedRef.current = true
@ -370,14 +379,18 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
if (matched) { if (matched) {
const matchedId = String(matched._id) const matchedId = String(matched._id)
setActiveTabKey(matchedId) setActiveTabKey(matchedId)
writeLastViewId(objectType, matchedId) if (!isElectron) {
writeLastViewId(objectType, matchedId)
}
setObjectListView(objectType, { setObjectListView(objectType, {
id: matchedId, id: matchedId,
name: matched.name || null name: matched.name || null
}) })
if (!matched._isDraft) { if (!matched._isDraft) {
// Keep viewId in the URL and strip any leftover filter/sort params persistView(matchedId, {
persistView(matchedId) scope: objectType,
name: matched.name || null
})
} }
urlSyncedRef.current = true urlSyncedRef.current = true
setViewSyncReady(true) setViewSyncReady(true)
@ -400,6 +413,8 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
draftViews, draftViews,
isEditing, isEditing,
objectType, objectType,
isElectron,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
rememberActiveView, rememberActiveView,
@ -903,36 +918,33 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
// route filter/sort changes into the draft instead of URL/session persistence. // route filter/sort changes into the draft instead of URL/session persistence.
const activeObjectView = isEditing ? activeView : null const activeObjectView = isEditing ? activeView : null
const handleViewModeChange = useCallback( const handleViewModeChange = useCallback(async (viewMode) => {
async (viewMode) => { const view = activeViewRef.current
const view = activeViewRef.current if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
if (!view || activeTabKeyRef.current === ALL_TAB_KEY) return
if (isEditingRef.current) { if (isEditingRef.current) {
const updateInList = (list) => const updateInList = (list) =>
list.map((item) => list.map((item) =>
String(item._id) === String(view._id) ? { ...item, viewMode } : item String(item._id) === String(view._id) ? { ...item, viewMode } : item
) )
activeViewRef.current = { ...view, viewMode } activeViewRef.current = { ...view, viewMode }
setDraftViews((prev) => updateInList(prev)) setDraftViews((prev) => updateInList(prev))
viewsStateRef.current = { viewsStateRef.current = {
...viewsStateRef.current, ...viewsStateRef.current,
draftViews: updateInList(viewsStateRef.current.draftViews) draftViews: updateInList(viewsStateRef.current.draftViews)
}
return
} }
return
}
// Not editing: store as a temporary user override instead of saving // Not editing: store as a temporary user override instead of saving
setUserViewModeOverride(viewMode) setUserViewModeOverride(viewMode)
userOverridesRef.current = { userOverridesRef.current = {
...(userOverridesRef.current || {}), ...(userOverridesRef.current || {}),
viewMode viewMode
} }
}, }, [])
[]
)
const isCustomView = activeTabKey !== ALL_TAB_KEY const isCustomView = activeTabKey !== ALL_TAB_KEY

View File

@ -9,6 +9,10 @@ import {
} from 'react' } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {
NavigationTabIdContext,
useNavigationTabs
} from './NavigationTabsContext'
const TableStateContext = createContext(null) const TableStateContext = createContext(null)
@ -166,6 +170,9 @@ const normalizeSorter = (sorter) => {
} }
export const TableStateProvider = ({ children }) => { export const TableStateProvider = ({ children }) => {
const tabId = useContext(NavigationTabIdContext)
const { isElectron, getTabListState, setTabListState, activeTab } =
useNavigationTabs()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [pageFilters, setPageFilters] = useState({}) const [pageFilters, setPageFilters] = useState({})
const [pageSorters, setPageSorters] = useState({}) const [pageSorters, setPageSorters] = useState({})
@ -214,8 +221,17 @@ export const TableStateProvider = ({ children }) => {
[setSearchParams] [setSearchParams]
) )
const resolveScopedTabId = useCallback(
() => tabId || (isElectron ? activeTab?.id : null),
[activeTab?.id, isElectron, tabId]
)
const getPersistedFilter = useCallback( const getPersistedFilter = useCallback(
(scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => { (scope, { useFilterInUrl = false, useFilterInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).filter || {}
}
if (useFilterInUrl) { if (useFilterInUrl) {
const fromUrl = readFilterFromUrl(searchParams) const fromUrl = readFilterFromUrl(searchParams)
if (fromUrl) return fromUrl if (fromUrl) return fromUrl
@ -226,11 +242,15 @@ export const TableStateProvider = ({ children }) => {
} }
return {} return {}
}, },
[searchParams] [getTabListState, isElectron, resolveScopedTabId, searchParams]
) )
const getPersistedSorter = useCallback( const getPersistedSorter = useCallback(
(scope, { useSortInUrl = false, useSortInSession = false } = {}) => { (scope, { useSortInUrl = false, useSortInSession = false } = {}) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, scope).sort || {}
}
if (useSortInUrl) { if (useSortInUrl) {
const fromUrl = readSortFromUrl(searchParams) const fromUrl = readSortFromUrl(searchParams)
if (fromUrl) return fromUrl if (fromUrl) return fromUrl
@ -241,7 +261,7 @@ export const TableStateProvider = ({ children }) => {
} }
return {} return {}
}, },
[searchParams] [getTabListState, isElectron, resolveScopedTabId, searchParams]
) )
const getViewFromUrl = useCallback( const getViewFromUrl = useCallback(
@ -249,8 +269,36 @@ export const TableStateProvider = ({ children }) => {
[searchParams] [searchParams]
) )
const getPersistedView = useCallback(
(scope) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
const fromUrl = readViewFromUrl(searchParams)
if (fromUrl) return { id: String(fromUrl), name: null }
return readListViewFromSession(scope)
},
[getTabListState, isElectron, resolveScopedTabId, searchParams]
)
const persistView = useCallback( const persistView = useCallback(
(viewId, { clearFilterSort = Boolean(viewId) } = {}) => { (
viewId,
{ clearFilterSort = Boolean(viewId), scope, name } = {}
) => {
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId && scope) {
setTabListState(scopedTabId, scope, {
viewId: viewId ? String(viewId) : null,
viewName: viewId ? name || null : null
})
return
}
updateSearchParams((next) => { updateSearchParams((next) => {
// Drop legacy `view` param that previously stored `_reference` // Drop legacy `view` param that previously stored `_reference`
next.delete('view') next.delete('view')
@ -266,7 +314,7 @@ export const TableStateProvider = ({ children }) => {
} }
}) })
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistFilter = useCallback( const persistFilter = useCallback(
@ -276,6 +324,13 @@ export const TableStateProvider = ({ children }) => {
{ saveFilterInSession = false, saveFilterInUrl = false } = {} { saveFilterInSession = false, saveFilterInUrl = false } = {}
) => { ) => {
const active = getActiveFilterValues(filterState) const active = getActiveFilterValues(filterState)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveFilterInSession || saveFilterInUrl) {
setTabListState(scopedTabId, scope, { filter: active })
}
return
}
if (saveFilterInSession) { if (saveFilterInSession) {
writeFilterToSession(scope, active) writeFilterToSession(scope, active)
} }
@ -289,7 +344,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistSort = useCallback( const persistSort = useCallback(
@ -299,6 +354,13 @@ export const TableStateProvider = ({ children }) => {
{ saveSortInSession = false, saveSortInUrl = false } = {} { saveSortInSession = false, saveSortInUrl = false } = {}
) => { ) => {
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
if (saveSortInSession || saveSortInUrl) {
setTabListState(scopedTabId, scope, { sort: nextSorter })
}
return
}
if (saveSortInSession) { if (saveSortInSession) {
writeSortToSession(scope, nextSorter) writeSortToSession(scope, nextSorter)
} }
@ -312,7 +374,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const persistTableState = useCallback( const persistTableState = useCallback(
@ -329,6 +391,21 @@ export const TableStateProvider = ({ children }) => {
) => { ) => {
const active = getActiveFilterValues(filterState) const active = getActiveFilterValues(filterState)
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const patch = {}
if (saveFilterInSession || saveFilterInUrl) {
patch.filter = active
}
if (saveSortInSession || saveSortInUrl) {
patch.sort = nextSorter
}
if (Object.keys(patch).length > 0) {
setTabListState(scopedTabId, scope, patch)
}
return
}
if (saveFilterInSession) { if (saveFilterInSession) {
writeFilterToSession(scope, active) writeFilterToSession(scope, active)
@ -356,7 +433,7 @@ export const TableStateProvider = ({ children }) => {
}) })
} }
}, },
[updateSearchParams] [isElectron, resolveScopedTabId, setTabListState, updateSearchParams]
) )
const setPageFilter = useCallback((path, filter) => { const setPageFilter = useCallback((path, filter) => {
@ -389,7 +466,12 @@ export const TableStateProvider = ({ children }) => {
const setObjectListFilter = useCallback((objectType, filter) => { const setObjectListFilter = useCallback((objectType, filter) => {
if (!objectType) return if (!objectType) return
const active = getActiveFilterValues(filter) const active = getActiveFilterValues(filter)
writeListFilterToSession(objectType, active) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listFilter: active })
} else {
writeListFilterToSession(objectType, active)
}
setObjectListFilters((prev) => { setObjectListFilters((prev) => {
const hasActive = Object.keys(active).length > 0 const hasActive = Object.keys(active).length > 0
if (!hasActive) { if (!hasActive) {
@ -400,12 +482,17 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: active } return { ...prev, [objectType]: active }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListSorter = useCallback((objectType, sorter) => { const setObjectListSorter = useCallback((objectType, sorter) => {
if (!objectType) return if (!objectType) return
const nextSorter = normalizeSorter(sorter) const nextSorter = normalizeSorter(sorter)
writeListSortToSession(objectType, nextSorter) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, { listSort: nextSorter })
} else {
writeListSortToSession(objectType, nextSorter)
}
setObjectListSorters((prev) => { setObjectListSorters((prev) => {
const hasSort = nextSorter?.field && nextSorter?.order const hasSort = nextSorter?.field && nextSorter?.order
if (!hasSort) { if (!hasSort) {
@ -416,7 +503,7 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: nextSorter } return { ...prev, [objectType]: nextSorter }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const setObjectListView = useCallback((objectType, view) => { const setObjectListView = useCallback((objectType, view) => {
if (!objectType) return if (!objectType) return
@ -424,7 +511,15 @@ export const TableStateProvider = ({ children }) => {
view?.id != null view?.id != null
? { id: String(view.id), name: view.name || null } ? { id: String(view.id), name: view.name || null }
: null : null
writeListViewToSession(objectType, nextView) const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
setTabListState(scopedTabId, objectType, {
viewId: nextView?.id || null,
viewName: nextView?.name || null
})
} else {
writeListViewToSession(objectType, nextView)
}
setObjectListViews((prev) => { setObjectListViews((prev) => {
if (!nextView) { if (!nextView) {
if (!(objectType in prev)) return prev if (!(objectType in prev)) return prev
@ -442,7 +537,7 @@ export const TableStateProvider = ({ children }) => {
} }
return { ...prev, [objectType]: nextView } return { ...prev, [objectType]: nextView }
}) })
}, []) }, [isElectron, resolveScopedTabId, setTabListState])
const getObjectListFilter = useCallback( const getObjectListFilter = useCallback(
(objectType) => { (objectType) => {
@ -450,9 +545,15 @@ export const TableStateProvider = ({ children }) => {
if (objectListFilters[objectType]) { if (objectListFilters[objectType]) {
return objectListFilters[objectType] return objectListFilters[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getActiveFilterValues(
getTabListState(scopedTabId, objectType).listFilter || {}
)
}
return getActiveFilterValues(readListFilterFromSession(objectType) || {}) return getActiveFilterValues(readListFilterFromSession(objectType) || {})
}, },
[objectListFilters] [getTabListState, isElectron, objectListFilters, resolveScopedTabId]
) )
const getObjectListSorter = useCallback( const getObjectListSorter = useCallback(
@ -461,9 +562,13 @@ export const TableStateProvider = ({ children }) => {
if (objectListSorters[objectType]) { if (objectListSorters[objectType]) {
return objectListSorters[objectType] return objectListSorters[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
return getTabListState(scopedTabId, objectType).listSort || {}
}
return readListSortFromSession(objectType) || {} return readListSortFromSession(objectType) || {}
}, },
[objectListSorters] [getTabListState, isElectron, objectListSorters, resolveScopedTabId]
) )
const getObjectListView = useCallback( const getObjectListView = useCallback(
@ -472,9 +577,16 @@ export const TableStateProvider = ({ children }) => {
if (objectListViews[objectType]) { if (objectListViews[objectType]) {
return objectListViews[objectType] return objectListViews[objectType]
} }
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, objectType)
return entry.viewId
? { id: String(entry.viewId), name: entry.viewName || null }
: null
}
return readListViewFromSession(objectType) return readListViewFromSession(objectType)
}, },
[objectListViews] [getTabListState, isElectron, objectListViews, resolveScopedTabId]
) )
const hasPageFilter = useCallback( const hasPageFilter = useCallback(
@ -484,6 +596,17 @@ export const TableStateProvider = ({ children }) => {
const hasStoredFilter = useCallback((scope) => { const hasStoredFilter = useCallback((scope) => {
if (!scope) return false if (!scope) return false
const scopedTabId = resolveScopedTabId()
if (isElectron && scopedTabId) {
const entry = getTabListState(scopedTabId, scope)
if (entry.viewId) {
return Object.keys(entry.listFilter || {}).length > 0
}
return (
Object.keys(entry.filter || {}).length > 0 ||
Object.keys(entry.listFilter || {}).length > 0
)
}
// When a view is active, only report the effective list filter (which // When a view is active, only report the effective list filter (which
// already accounts for the view). Don't fall back to the All-tab session // already accounts for the view). Don't fall back to the All-tab session
// filter — that belongs to a different context. // filter — that belongs to a different context.
@ -493,12 +616,19 @@ export const TableStateProvider = ({ children }) => {
if (hasStoredSessionFilter(scope)) return true if (hasStoredSessionFilter(scope)) return true
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0 return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
}, [objectListFilters, objectListViews]) }, [
getTabListState,
isElectron,
objectListFilters,
objectListViews,
resolveScopedTabId
])
const value = useMemo( const value = useMemo(
() => ({ () => ({
getPersistedFilter, getPersistedFilter,
getPersistedSorter, getPersistedSorter,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
persistFilter, persistFilter,
@ -523,6 +653,7 @@ export const TableStateProvider = ({ children }) => {
[ [
getPersistedFilter, getPersistedFilter,
getPersistedSorter, getPersistedSorter,
getPersistedView,
getViewFromUrl, getViewFromUrl,
persistView, persistView,
persistFilter, persistFilter,

View File

@ -5,9 +5,7 @@ import { AuthContext } from '../context/AuthContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext' import { useNavigationTabPage } from '../context/NavigationTabsContext'
const formatPageLabel = (pageName) => const formatPageLabel = (pageName) =>
pageName pageName ? `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)}` : 'Page'
? `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)}`
: 'Page'
const getObjectDisplayName = (objectData, model) => { const getObjectDisplayName = (objectData, model) => {
if (objectData?.name) return objectData.name if (objectData?.name) return objectData.name
@ -17,18 +15,9 @@ const getObjectDisplayName = (objectData, model) => {
return null return null
} }
export const formatObjectPageTabTitle = ({ export const formatObjectPageTabTitle = ({ pageName, objectName } = {}) => {
pageName,
model,
objectName
} = {}) => {
const pageLabel = formatPageLabel(pageName) const pageLabel = formatPageLabel(pageName)
const collectionLabel = model?.labelPlural if (objectName) return `${objectName} - ${pageLabel}`
if (objectName && collectionLabel) {
return `${pageLabel} (${objectName}) - ${collectionLabel}`
}
if (objectName) return `${pageLabel} (${objectName})`
if (collectionLabel) return `${pageLabel} - ${collectionLabel}`
return pageLabel return pageLabel
} }
@ -74,7 +63,7 @@ export const useObjectNavigationTabPage = ({
}, [connected, model, modelName, objectId, subscribeToObjectUpdates]) }, [connected, model, modelName, objectId, subscribeToObjectUpdates])
useNavigationTabPage({ useNavigationTabPage({
title: formatObjectPageTabTitle({ pageName, model, objectName }), title: formatObjectPageTabTitle({ pageName, objectName }),
modelName modelName
}) })
} }