Enhance FilterSidebar and ObjectTable Components with View Filter and User Overrides
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added support for viewFilter and isEditing props in FilterSidebar to manage filter states more effectively.
- Updated ObjectTable to handle user overrides for filter and sort states when switching views, ensuring accurate data representation.
- Integrated userOverridesRef to maintain filter and sort states during editing, improving user experience when managing views.
- Adjusted loading logic in ObjectTableNavigationButtons to prioritize effective filters based on active views, enhancing data consistency.
This commit is contained in:
Tom Butcher 2026-09-03 16:31:07 +01:00
parent 3d1926aba0
commit 789e66b0f3
5 changed files with 218 additions and 133 deletions

View File

@ -45,7 +45,9 @@ const FilterSidebar = ({
type,
filter = {},
onFilterChange,
masterFilter = {}
masterFilter = {},
viewFilter = {},
isEditing = false
}) => {
const model = getModelByName(type)
const modelProps = getModelProperties(type)
@ -113,6 +115,11 @@ const FilterSidebar = ({
[filterableProperties]
)
const viewFilterKeys = useMemo(
() => new Set(Object.keys(viewFilter).filter((k) => viewFilter[k] !== '')),
[viewFilter]
)
const activeFields = Object.keys(localFilter)
const usedFields = new Set(activeFields)
@ -192,7 +199,10 @@ const FilterSidebar = ({
/>
</Flex>
<Flex vertical gap={8}>
{rows.map((row) => (
{rows.map((row) => {
const isViewRow =
viewFilterKeys.has(row.field) && !isEditing
return (
<Space.Compact key={row.field} style={{ width: '100%' }}>
<Select
placeholder='Field'
@ -200,6 +210,7 @@ const FilterSidebar = ({
onChange={(v) => changeField(row.field, v)}
options={availableOptions(row.field)}
allowClear={false}
disabled={isViewRow}
labelRender={({ label }) => (
<FieldSelectLabel label={label} options={fieldOptions} />
)}
@ -211,6 +222,7 @@ const FilterSidebar = ({
value={row.value}
onChange={(value) => changeValue(row.field, value)}
style={{ flex: 1 }}
disabled={isViewRow}
propertyFilter={{
modelType: type,
propertyName: row.field
@ -222,9 +234,11 @@ const FilterSidebar = ({
icon={<CloseOutlined />}
onClick={() => removeRow(row.field)}
title='Remove filter'
disabled={isViewRow}
/>
</Space.Compact>
))}
)
})}
{rows.length === 0 && (
<MissingPlaceholder message='No filters applied.' />
)}
@ -238,7 +252,9 @@ FilterSidebar.propTypes = {
type: PropTypes.string.isRequired,
filter: PropTypes.object,
onFilterChange: PropTypes.func.isRequired,
masterFilter: PropTypes.object
masterFilter: PropTypes.object,
viewFilter: PropTypes.object,
isEditing: PropTypes.bool
}
export default FilterSidebar

View File

@ -329,6 +329,7 @@ const ObjectTable = forwardRef(
const listViewFilter = objectListView?.listViewFilter ?? null
const listViewSort = objectListView?.listViewSort ?? null
const activeObjectView = objectListView?.activeObjectView ?? null
const userOverridesRef = objectListView?.userOverridesRef ?? null
const onObjectViewFilterSortChange = objectListView?.isEditing
? objectListView.handleFilterSortChange
: null
@ -1369,12 +1370,6 @@ const ObjectTable = forwardRef(
if (lastAppliedViewKeyRef.current === viewKey) return
// Prop lag: listViewId is set but filter/sort props are still empty.
// Never overwrite a non-empty sidebar/query with {}.
const hasViewState =
Object.keys(viewFilter).length > 0 || Boolean(viewSort.field)
if (!hasViewState) return
lastAppliedViewKeyRef.current = viewKey
assignSidebarToView(listViewId)
@ -1394,6 +1389,9 @@ const ObjectTable = forwardRef(
const applyViewState = useCallback(
(filterState, sorterState, options = {}) => {
// Switching views clears user overrides
if (userOverridesRef) userOverridesRef.current = null
let filter
let sorter
@ -1630,7 +1628,16 @@ const ObjectTable = forwardRef(
listViewFilterRef.current = next
// Editing a view: keep filter/sort on the draft view only
onObjectViewFilterSortChangeRef.current?.(next, nextSorter)
} else if (!listViewIdRef.current) {
} else if (listViewIdRef.current) {
// On a view but not editing: track as user override for startEditing
if (userOverridesRef) {
userOverridesRef.current = {
...(userOverridesRef.current || {}),
filter: next,
sort: nextSorter
}
}
} else {
// All tab: persist personal filter/sort to URL/session
persistTableState(next, nextSorter)
}
@ -1651,7 +1658,20 @@ const ObjectTable = forwardRef(
newSidebarFilter,
tableSorter
)
} else if (!listViewIdRef.current) {
} else if (listViewIdRef.current) {
// On a view but not editing: track as user override for startEditing
if (userOverridesRef) {
userOverridesRef.current = {
...(userOverridesRef.current || {}),
filter: newSidebarFilter,
sort:
userOverridesRef.current?.sort ??
(tableSorterRef.current?.field
? tableSorterRef.current
: undefined)
}
}
} else {
persistFilter(newSidebarFilter)
}
const effective = buildEffectiveFilter(newSidebarFilter)
@ -1667,7 +1687,8 @@ const ObjectTable = forwardRef(
loadPage,
persistFilter,
resolveSorter,
tableSorter
tableSorter,
userOverridesRef
]
)
@ -1681,7 +1702,16 @@ const ObjectTable = forwardRef(
tableSorterRef.current = nextSorter
if (activeObjectViewRef.current) {
onObjectViewFilterSortChangeRef.current?.(sidebarFilter, nextSorter)
} else if (!listViewIdRef.current) {
} else if (listViewIdRef.current) {
// On a view but not editing: track as user override for startEditing
if (userOverridesRef) {
userOverridesRef.current = {
...(userOverridesRef.current || {}),
sort: nextSorter,
filter: userOverridesRef.current?.filter ?? sidebarFilter
}
}
} else {
persistSort(nextSorter)
}
const effective = buildEffectiveFilter(sidebarFilter)
@ -1696,7 +1726,8 @@ const ObjectTable = forwardRef(
persistSort,
resolveSorter,
sidebarFilter,
suppressTableChange
suppressTableChange,
userOverridesRef
]
)
@ -2157,6 +2188,8 @@ const ObjectTable = forwardRef(
}
onFilterChange={handleSidebarFilterChange}
masterFilter={resolvedMasterFilter}
viewFilter={listViewFilter}
isEditing={!!activeObjectView}
/>
</Flex>
)}

View File

@ -26,7 +26,8 @@ const ObjectTableNavigationButtons = ({
const [neighbors, setNeighbors] = useState({ next: null, previous: null })
const [loading, setLoading] = useState(false)
const { getObjectListFilter, getObjectListSorter } = useTableState()
const { getObjectListFilter, getObjectListSorter, getObjectListView } =
useTableState()
const { getPersistedFilter, getPersistedSorter } = useTableStatePersistence({
scope: objectType,
useFilterInSession: true,
@ -42,6 +43,9 @@ const ObjectTableNavigationButtons = ({
const getObjectListSorterRef = useRef(getObjectListSorter)
getObjectListSorterRef.current = getObjectListSorter
const getObjectListViewRef = useRef(getObjectListView)
getObjectListViewRef.current = getObjectListView
const getPersistedFilterRef = useRef(getPersistedFilter)
getPersistedFilterRef.current = getPersistedFilter
@ -59,18 +63,23 @@ const ObjectTableNavigationButtons = ({
const loadNeighbors = async () => {
setLoading(true)
// Prefer the effective list filter (view + user filters) when available
// When a view is active, use its effective filter exclusively — never
// fall back to the All-tab persisted filter which belongs to a different
// context and would produce wrong neighbors.
const hasActiveView = !!getObjectListViewRef.current(objectType)
const listFilter = getActiveFilterValues(
getObjectListFilterRef.current(objectType)
)
const persistedFilter = getActiveFilterValues(
getPersistedFilterRef.current()
)
const persistedFilter = hasActiveView
? {}
: getActiveFilterValues(getPersistedFilterRef.current())
const filter =
Object.keys(listFilter).length > 0 ? listFilter : persistedFilter
const listSorter = getObjectListSorterRef.current(objectType)
const persistedSorter = getPersistedSorterRef.current()
const persistedSorter = hasActiveView
? {}
: getPersistedSorterRef.current()
const sorter =
listSorter?.field && listSorter?.order ? listSorter : persistedSorter

View File

@ -13,10 +13,7 @@ import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext'
import { useTableState } from './TableStateContext'
import useViewMode from '../hooks/useViewMode'
import {
DEFAULT_VIEW_MODE,
normalizeViewMode
} from '../common/viewModeUtils'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
const ObjectListViewContext = createContext()
@ -25,8 +22,7 @@ const ALL_TAB_KEY = 'all'
const getLastViewStorageKey = (objectType) =>
`objectListView:${objectType}:lastViewId`
const getViewsCacheKey = (objectType) =>
`objectListView:${objectType}:views`
const getViewsCacheKey = (objectType) => `objectListView:${objectType}:views`
const readLastViewId = (objectType) => {
if (!objectType) return null
@ -99,9 +95,7 @@ export const getObjectViewKey = (view) =>
const resolveViewFromId = (views, viewId) => {
if (!viewId) return null
return (
views.find((view) => String(view._id) === String(viewId)) || null
)
return views.find((view) => String(view._id) === String(viewId)) || null
}
const cloneViews = (views) =>
@ -121,11 +115,7 @@ const viewStateEqual = (left, right) =>
const viewModeEqual = (left, right) =>
JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
export const ObjectListViewProvider = ({
children,
objectType,
tableRef
}) => {
export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
const {
fetchObjectViews,
createObjectView,
@ -138,9 +128,7 @@ export const ObjectListViewProvider = ({
const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView, setObjectListView } = useTableState()
const [views, setViews] = useState(
() => readCachedViews(objectType) || []
)
const [views, setViews] = useState(() => readCachedViews(objectType) || [])
const [draftViews, setDraftViews] = useState(() => {
const cached = readCachedViews(objectType)
return cached ? cloneViews(cached) : []
@ -155,10 +143,14 @@ export const ObjectListViewProvider = ({
activeTabKeyRef.current = activeTabKey
const [isEditing, setIsEditing] = useState(false)
const [viewSyncReady, setViewSyncReady] = useState(false)
const [userViewModeOverride, setUserViewModeOverride] = useState(null)
const editSnapshotRef = useRef([])
const wasEditingRef = useRef(false)
const isEditingRef = useRef(isEditing)
// ObjectTable writes its current user overrides (filter/sort) here so
// startEditing can merge them into the draft view without prop drilling.
const userOverridesRef = useRef(null)
const activeViewRef = useRef(null)
const viewsStateRef = useRef({ views, draftViews, isEditing })
const urlSyncedRef = useRef(false)
@ -188,8 +180,9 @@ export const ObjectListViewProvider = ({
const activeView = useMemo(() => {
if (activeTabKey === ALL_TAB_KEY) return null
const view =
displayedViews.find((item) => String(item._id) === String(activeTabKey)) ||
null
displayedViews.find(
(item) => String(item._id) === String(activeTabKey)
) || null
if (view?._isDraft) {
return { ...view, filter: view.filter || {}, sort: view.sort || {} }
}
@ -213,8 +206,11 @@ export const ObjectListViewProvider = ({
(tabKey) => {
if (!tableRef?.current?.applyViewState) return
const { views: savedViews, draftViews: drafts, isEditing: editing } =
viewsStateRef.current
const {
views: savedViews,
draftViews: drafts,
isEditing: editing
} = viewsStateRef.current
const list = editing ? drafts : savedViews
const view =
tabKey === ALL_TAB_KEY
@ -289,10 +285,7 @@ export const ObjectListViewProvider = ({
persistView(key)
writeLastViewId(objectType, key)
setObjectListView(
objectType,
key ? { id: key, name } : null
)
setObjectListView(objectType, key ? { id: key, name } : null)
},
[objectType, persistView, setObjectListView]
)
@ -402,6 +395,8 @@ export const ObjectListViewProvider = ({
(tabKey) => {
if (String(tabKey) === String(activeTabKey)) return
setUserViewModeOverride(null)
userOverridesRef.current = null
setActiveTabKey(tabKey)
if (tabKey === ALL_TAB_KEY) {
@ -431,7 +426,24 @@ export const ObjectListViewProvider = ({
const startEditing = useCallback(() => {
const snapshot = cloneViews(views)
editSnapshotRef.current = snapshot
// If the user had filter/sort/viewMode overrides while browsing a view,
// merge them into the active draft so the save picks them up.
const overrides = userOverridesRef.current
if (overrides && activeTabKeyRef.current !== ALL_TAB_KEY) {
const activeId = activeTabKeyRef.current
for (const draft of snapshot) {
if (String(draft._id) !== String(activeId)) continue
if (overrides.filter) draft.filter = overrides.filter
if (overrides.sort) draft.sort = overrides.sort
if (overrides.viewMode !== undefined)
draft.viewMode = overrides.viewMode
}
}
setDraftViews(snapshot)
setUserViewModeOverride(null)
userOverridesRef.current = null
setIsEditing(true)
}, [views])
@ -440,6 +452,7 @@ export const ObjectListViewProvider = ({
setDraftViews(snapshot)
setViews(snapshot)
setIsEditing(false)
userOverridesRef.current = null
if (activeTabKey !== ALL_TAB_KEY) {
const stillExists = snapshot.some(
@ -458,8 +471,12 @@ export const ObjectListViewProvider = ({
const saveEdits = useCallback(async () => {
setSaving(true)
try {
const originalById = new Map(views.map((view) => [String(view._id), view]))
const draftById = new Map(draftViews.map((view) => [String(view._id), view]))
const originalById = new Map(
views.map((view) => [String(view._id), view])
)
const draftById = new Map(
draftViews.map((view) => [String(view._id), view])
)
let newActiveId = activeTabKey
@ -542,13 +559,7 @@ export const ObjectListViewProvider = ({
} finally {
setSaving(false)
}
}, [
activeTabKey,
draftViews,
objectType,
rememberActiveView,
views
])
}, [activeTabKey, draftViews, objectType, rememberActiveView, views])
const handleTabEdit = useCallback(
async (targetKeyOrEvent, action) => {
@ -642,7 +653,8 @@ export const ObjectListViewProvider = ({
)
}, [])
const reorderDraftViews = useCallback((fromValue, toValue, insertBefore) => {
const reorderDraftViews = useCallback(
(fromValue, toValue, insertBefore) => {
if (!isEditing) return
setDraftViews((prev) => {
@ -669,13 +681,17 @@ export const ObjectListViewProvider = ({
next.splice(targetIndex, 0, moved)
return next
})
}, [isEditing])
},
[isEditing]
)
const mergeViewUpdate = useCallback(
(viewId, updatedData) => {
const mergeOne = (prev) =>
prev.map((view) =>
String(view._id) === String(viewId) ? { ...view, ...updatedData } : view
String(view._id) === String(viewId)
? { ...view, ...updatedData }
: view
)
setViews(mergeOne)
@ -694,7 +710,9 @@ export const ObjectListViewProvider = ({
? getPreviousTabKey(list, viewId)
: null
setViews((prev) => prev.filter((view) => String(view._id) !== String(viewId)))
setViews((prev) =>
prev.filter((view) => String(view._id) !== String(viewId))
)
if (!isEditing) {
setDraftViews((prev) =>
prev.filter((view) => String(view._id) !== String(viewId))
@ -716,7 +734,14 @@ export const ObjectListViewProvider = ({
applyViewToTable(previousKey)
}
},
[activeTabKey, applyViewToTable, draftViews, isEditing, rememberActiveView, views]
[
activeTabKey,
applyViewToTable,
draftViews,
isEditing,
rememberActiveView,
views
]
)
const handleFilterSortChange = useCallback((filter, sorter) => {
@ -767,10 +792,7 @@ export const ObjectListViewProvider = ({
updateEventHandlerRef.current = updateEventHandler
const subscriptionFilter = useMemo(
() => ({ objectType }),
[objectType]
)
const subscriptionFilter = useMemo(() => ({ objectType }), [objectType])
useEffect(() => {
if (connected !== true || !objectType || !authReady) return
@ -860,9 +882,7 @@ export const ObjectListViewProvider = ({
? String(activeTabKey)
: null
const listViewFilter = activeView?.filter || EMPTY_FILTER
const listViewSort = activeView?.sort?.field
? activeView.sort
: EMPTY_SORT
const listViewSort = activeView?.sort?.field ? activeView.sort : EMPTY_SORT
const listViewViewMode = activeView?.viewMode ?? null
// Only expose the editable view while editing — ObjectTable uses this to
// route filter/sort changes into the draft instead of URL/session persistence.
@ -876,9 +896,7 @@ export const ObjectListViewProvider = ({
if (isEditingRef.current) {
const updateInList = (list) =>
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 }
@ -891,20 +909,22 @@ export const ObjectListViewProvider = ({
return
}
try {
await apiRef.current.updateObjectView(view._id, { viewMode })
mergeViewUpdate(view._id, { viewMode })
} catch (error) {
console.error('Error updating view mode:', error)
message.error('Failed to save view mode')
// Not editing: store as a temporary user override instead of saving
setUserViewModeOverride(viewMode)
userOverridesRef.current = {
...(userOverridesRef.current || {}),
viewMode
}
},
[mergeViewUpdate]
[]
)
const isCustomView = activeTabKey !== ALL_TAB_KEY
const viewMode = useMemo(() => {
if (isCustomView && userViewModeOverride) {
return normalizeViewMode(userViewModeOverride)
}
if (isCustomView && listViewViewMode) {
return normalizeViewMode(listViewViewMode)
}
@ -912,7 +932,7 @@ export const ObjectListViewProvider = ({
return DEFAULT_VIEW_MODE
}
return normalizeViewMode(userViewMode)
}, [isCustomView, listViewViewMode, userViewMode])
}, [isCustomView, listViewViewMode, userViewMode, userViewModeOverride])
const setViewMode = useCallback(
(nextMode) => {
@ -963,6 +983,7 @@ export const ObjectListViewProvider = ({
removeView,
handleFilterSortChange,
handleViewModeChange,
userOverridesRef,
reloadViews: () => loadViewsRef.current(true),
viewTabSignatures
}),

View File

@ -481,10 +481,16 @@ export const TableStateProvider = ({ children }) => {
const hasStoredFilter = useCallback((scope) => {
if (!scope) return false
// 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
// filter — that belongs to a different context.
if (objectListViews[scope]) {
return Object.keys(objectListFilters[scope] || {}).length > 0
}
if (hasStoredSessionFilter(scope)) return true
if (Object.keys(objectListFilters[scope] || {}).length > 0) return true
return Object.keys(getActiveFilterValues(readListFilterFromSession(scope) || {})).length > 0
}, [objectListFilters])
}, [objectListFilters, objectListViews])
const value = useMemo(
() => ({