Enhance Filter Functionality Across Dashboard Components
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Updated FilterInput, FilterSidebar, ObjectTable, SimpleDateTimePropertyFilter, and SimplePropertyFilter components to support new filter and masterFilter props, improving data filtering capabilities.
- Introduced ObjectTableFilterContext for better state management of filter values across components, enhancing the overall user experience.
- Refactored getModelPropertyValues function in ApiServerContext to accept filter parameters, allowing for more dynamic data fetching based on user-defined filters.
- Improved loading and visibility handling in SimpleDateTimePropertyFilter and SimplePropertyFilter to optimize performance during data retrieval.
This commit is contained in:
Tom Butcher 2026-08-30 12:26:12 +01:00
parent f447ec287a
commit 0ae4f1fae0
7 changed files with 235 additions and 30 deletions

View File

@ -442,7 +442,9 @@ const FilterInput = ({
onBlur,
onPressEnter,
size,
propertyFilter = null
propertyFilter = null,
filter = {},
masterFilter = {}
}) => {
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
const prefixCls = getPrefixCls('input')
@ -895,6 +897,10 @@ const FilterInput = ({
propertyName={propertyFilter.propertyName}
value={propertyFilterValue}
onChange={handlePropertyFilterChange}
filter={filter}
masterFilter={masterFilter}
visible={true}
useTableFilter={false}
/>
</div>
</div>
@ -1146,7 +1152,8 @@ const FilterInput = ({
{propertyFilterEnabled && (
<Popover
open={focused}
content={propertyFilterContent}
destroyOnHidden={true}
content={focused ? propertyFilterContent : null}
placement='bottomLeft'
arrow={false}
trigger={[]}
@ -1191,7 +1198,9 @@ FilterInput.propTypes = {
propertyFilter: PropTypes.shape({
modelType: PropTypes.string.isRequired,
propertyName: PropTypes.string.isRequired
})
}),
filter: PropTypes.object,
masterFilter: PropTypes.object
}
export default FilterInput

View File

@ -215,6 +215,8 @@ const FilterSidebar = ({
modelType: type,
propertyName: row.field
}}
filter={localFilter}
masterFilter={masterFilter}
/>
<Button
icon={<CloseOutlined />}

View File

@ -56,6 +56,7 @@ import {
} from '../context/TableStateContext'
import { hasActionPermission } from '../../../database/permissions'
import Tooltip from './Tooltip'
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
@ -98,7 +99,9 @@ const ColumnFilterDropdown = ({
visible,
propertyName,
propertyLabel,
modelType
modelType,
filter = {},
masterFilter = {}
}) => {
const [expression, setExpression] = useState('')
const [draft, setDraft] = useState(selectedKeys || [])
@ -164,6 +167,9 @@ const ColumnFilterDropdown = ({
propertyName={propertyName}
value={draft}
onChange={handleDraftChange}
filter={filter}
masterFilter={masterFilter}
visible={visible}
/>
</div>
</ScrollBox>
@ -181,7 +187,9 @@ ColumnFilterDropdown.propTypes = {
visible: PropTypes.bool,
propertyName: PropTypes.string,
propertyLabel: PropTypes.string,
modelType: PropTypes.string
modelType: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object
}
const RowForm = ({ record, isEditing, onRegister, children }) => {
@ -867,6 +875,14 @@ const ObjectTable = forwardRef(
return { ...active, ...masterFilter }
}, [sidebarFilter, masterFilter])
const tableFilterContextValue = useMemo(
() => ({
filter: getActiveFilterValues(sidebarFilter),
masterFilter
}),
[sidebarFilter, masterFilter]
)
newEventHandlerRef.current = newEventHandler
subscriptionFilterRef.current = subscriptionFilter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
@ -1112,6 +1128,8 @@ const ObjectTable = forwardRef(
propertyName={propertyName}
propertyLabel={propertyLabel}
modelType={type}
filter={sidebarFilter}
masterFilter={masterFilter}
/>
)
@ -1583,7 +1601,11 @@ const ObjectTable = forwardRef(
</Flex>
)
return tableContent
return (
<ObjectTableFilterContext.Provider value={tableFilterContextValue}>
{tableContent}
</ObjectTableFilterContext.Provider>
)
}
)

View File

@ -0,0 +1,3 @@
import { createContext } from 'react'
export const ObjectTableFilterContext = createContext(null)

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useMemo, useCallback } from 'react'
import { useState, useEffect, useContext, useMemo, useCallback, useRef } from 'react'
import { Spin, Tree, Flex, Checkbox, Typography } from 'antd'
import PropTypes from 'prop-types'
import dayjs from 'dayjs'
@ -369,31 +369,94 @@ const expandKeysForChecked = (keys, nodeByKey) => {
return [...checked]
}
const EMPTY_OBJECT = {}
const stableStringify = (value) => {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
const getFilterHash = (filter, masterFilter) =>
`${stableStringify(filter || {})}:${stableStringify(masterFilter || {})}`
const SimpleDateTimePropertyFilter = ({
modelType,
propertyName,
value = [],
onChange,
search = ''
search = '',
filter = EMPTY_OBJECT,
masterFilter = EMPTY_OBJECT,
visible = true
}) => {
const { getModelPropertyValues } = useContext(ApiServerContext)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
getModelPropertyValuesRef.current = getModelPropertyValues
const [dates, setDates] = useState([])
const [loading, setLoading] = useState(false)
const [localChecked, setLocalChecked] = useState(null)
const filterForValues = useMemo(() => {
const next = { ...(filter || {}) }
if (propertyName) delete next[propertyName]
return next
}, [filter, propertyName])
const masterFilterForValues = masterFilter || EMPTY_OBJECT
const filterHash = useMemo(
() => getFilterHash(filterForValues, masterFilterForValues),
[filterForValues, masterFilterForValues]
)
const loadedFilterHashRef = useRef(null)
const filterHashRef = useRef(filterHash)
const filterForValuesRef = useRef(filterForValues)
const masterFilterForValuesRef = useRef(masterFilterForValues)
const wasVisibleRef = useRef(false)
filterHashRef.current = filterHash
filterForValuesRef.current = filterForValues
masterFilterForValuesRef.current = masterFilterForValues
useEffect(() => {
const becameVisible = visible && !wasVisibleRef.current
wasVisibleRef.current = visible
if (!visible) return
if (!becameVisible && loadedFilterHashRef.current != null) return
let cancelled = false
const loadOptions = async () => {
if (!modelType || !propertyName) return
const currentHash = filterHashRef.current
if (loadedFilterHashRef.current === currentHash) return
setDates([])
setLoading(true)
try {
const values = await getModelPropertyValues(modelType, propertyName)
const values = await getModelPropertyValuesRef.current(
modelType,
propertyName,
{
filter: filterForValuesRef.current,
masterFilter: masterFilterForValuesRef.current
}
)
if (cancelled) return
const parsed = (values || [])
.map(parseDateValue)
.filter(Boolean)
.sort((a, b) => a.getTime() - b.getTime())
loadedFilterHashRef.current = currentHash
setDates(parsed)
} finally {
if (!cancelled) setLoading(false)
@ -404,7 +467,7 @@ const SimpleDateTimePropertyFilter = ({
return () => {
cancelled = true
}
}, [modelType, propertyName, getModelPropertyValues])
}, [visible, modelType, propertyName])
const treeData = useMemo(
() => buildTreeData(buildDateHierarchy(dates)),
@ -569,7 +632,10 @@ SimpleDateTimePropertyFilter.propTypes = {
propertyName: PropTypes.string,
value: PropTypes.array,
onChange: PropTypes.func,
search: PropTypes.string
search: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object,
visible: PropTypes.bool
}
export default SimpleDateTimePropertyFilter

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useContext, useMemo, useRef } from 'react'
import { Flex, Checkbox, Spin, Typography } from 'antd'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
import {
getModelByName,
getModelProperties
@ -50,27 +51,91 @@ const matchOptions = (options, selected) => {
return options.filter((option) => selectedKeys.has(getOptionKey(option)))
}
const stableStringify = (value) => {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
const getFilterHash = (filter, masterFilter) =>
`${stableStringify(filter || {})}:${stableStringify(masterFilter || {})}`
const omitUndefinedValues = (obj) =>
Object.fromEntries(
Object.entries(obj || {}).filter(
([, value]) => value !== undefined && value !== ''
)
)
// Survive table reloads / popover remounts without refetching the same property.
const optionsCache = new Map()
const lastLoadedFilterHashByProperty = new Map()
const EMPTY_OBJECT = {}
const SimplePropertyFilter = ({
modelType,
propertyName,
value = [],
onChange,
search = ''
search = '',
filter = EMPTY_OBJECT,
masterFilter = EMPTY_OBJECT,
visible = true,
useTableFilter = true
}) => {
const { getModelPropertyValues } = useContext(ApiServerContext)
const tableFilterContext = useContext(ObjectTableFilterContext)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
getModelPropertyValuesRef.current = getModelPropertyValues
const resolvedFilter = useTableFilter
? (tableFilterContext?.filter ?? filter)
: filter
const resolvedMasterFilter = useTableFilter
? (tableFilterContext?.masterFilter ?? masterFilter)
: masterFilter || tableFilterContext?.masterFilter || EMPTY_OBJECT
const filterForValues = useMemo(() => {
const next = omitUndefinedValues(resolvedFilter)
if (propertyName) delete next[propertyName]
return next
}, [resolvedFilter, propertyName])
const masterFilterForValues = useMemo(
() => omitUndefinedValues(resolvedMasterFilter),
[resolvedMasterFilter]
)
const filterHash = useMemo(
() => getFilterHash(filterForValues, masterFilterForValues),
[filterForValues, masterFilterForValues]
)
const loadedFilterHashRef = useRef(null)
const filterHashRef = useRef(filterHash)
const filterForValuesRef = useRef(filterForValues)
const masterFilterForValuesRef = useRef(masterFilterForValues)
const wasVisibleRef = useRef(false)
filterHashRef.current = filterHash
filterForValuesRef.current = filterForValues
masterFilterForValuesRef.current = masterFilterForValues
const cacheKey =
modelType && propertyName ? `${modelType}:${propertyName}` : null
modelType && propertyName
? `${modelType}:${propertyName}:${filterHash}`
: null
const [options, setOptions] = useState(
() => (cacheKey && optionsCache.get(cacheKey)) || []
)
const [loading, setLoading] = useState(
() => !(cacheKey && optionsCache.has(cacheKey))
() => visible && !(cacheKey && optionsCache.has(cacheKey))
)
const [localChecked, setLocalChecked] = useState(null)
@ -81,6 +146,11 @@ const SimplePropertyFilter = ({
)
useEffect(() => {
const becameVisible = visible && !wasVisibleRef.current
wasVisibleRef.current = visible
if (!visible) return
if (!becameVisible && loadedFilterHashRef.current != null) return
let cancelled = false
const loadOptions = async () => {
@ -89,25 +159,40 @@ const SimplePropertyFilter = ({
return
}
const key = `${modelType}:${propertyName}`
const cached = optionsCache.get(key)
if (cached) {
setOptions(cached)
setLoading(false)
return
const currentHash = filterHashRef.current
const propertyKey = `${modelType}:${propertyName}`
const lastHash =
loadedFilterHashRef.current ??
lastLoadedFilterHashByProperty.get(propertyKey)
if (lastHash === currentHash) {
loadedFilterHashRef.current = currentHash
const cached = optionsCache.get(`${propertyKey}:${currentHash}`)
if (cached) {
setOptions(cached)
setLoading(false)
return
}
}
setOptions([])
setLoading(true)
try {
const values = await getModelPropertyValuesRef.current(
modelType,
propertyName
propertyName,
{
filter: filterForValuesRef.current,
masterFilter: masterFilterForValuesRef.current
}
)
if (cancelled) return
const unique = [
...new Set((values || []).filter((v) => v != null && v !== ''))
]
optionsCache.set(key, unique)
optionsCache.set(`${propertyKey}:${currentHash}`, unique)
lastLoadedFilterHashByProperty.set(propertyKey, currentHash)
loadedFilterHashRef.current = currentHash
setOptions(unique)
} finally {
if (!cancelled) setLoading(false)
@ -118,9 +203,8 @@ const SimplePropertyFilter = ({
return () => {
cancelled = true
}
// Intentionally omit getModelPropertyValues — context recreates it on every
// provider render (e.g. table fetchLoading), which would refetch endlessly.
}, [modelType, propertyName, property])
// Reload only when this filter becomes visible, then compare filterHash.
}, [visible, modelType, propertyName, property])
const valueKey = useMemo(
() => JSON.stringify((value || []).map(getOptionKey)),
@ -179,6 +263,9 @@ const SimplePropertyFilter = ({
value={value}
onChange={onChange}
search={search}
filter={resolvedFilter}
masterFilter={resolvedMasterFilter}
visible={visible}
/>
)
}
@ -225,7 +312,11 @@ SimplePropertyFilter.propTypes = {
propertyName: PropTypes.string,
value: PropTypes.array,
onChange: PropTypes.func,
search: PropTypes.string
search: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object,
visible: PropTypes.bool,
useTableFilter: PropTypes.bool
}
export default SimplePropertyFilter

View File

@ -1874,17 +1874,29 @@ const ApiServerProvider = ({ children }) => {
}
}
const getModelPropertyValues = async (objectType, property) => {
const getModelPropertyValues = async (objectType, property, params = {}) => {
const { filter = {}, masterFilter = {} } = params
logger.debug(
'Fetching property values for model type:',
objectType,
property
property,
{ filter, masterFilter }
)
try {
const response = await axios.get(
`${config.backendUrl}/${getObjectEndpoint(objectType)}/values`,
{
params: { property },
params: {
property,
...Object.keys(filter).reduce((acc, key) => {
acc[key] = Array.isArray(filter[key])
? filter[key].join(',')
: filter[key]
return acc
}, {}),
masterFilter: JSON.stringify(masterFilter)
},
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
@ -1900,7 +1912,7 @@ const ApiServerProvider = ({ children }) => {
} catch (err) {
console.error(err)
showError(err, () => {
getModelPropertyValues(objectType, property)
getModelPropertyValues(objectType, property, params)
})
return []
}