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

View File

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

View File

@ -56,6 +56,7 @@ import {
} from '../context/TableStateContext' } from '../context/TableStateContext'
import { hasActionPermission } from '../../../database/permissions' import { hasActionPermission } from '../../../database/permissions'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
const logger = loglevel.getLogger('DasboardTable') const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -98,7 +99,9 @@ const ColumnFilterDropdown = ({
visible, visible,
propertyName, propertyName,
propertyLabel, propertyLabel,
modelType modelType,
filter = {},
masterFilter = {}
}) => { }) => {
const [expression, setExpression] = useState('') const [expression, setExpression] = useState('')
const [draft, setDraft] = useState(selectedKeys || []) const [draft, setDraft] = useState(selectedKeys || [])
@ -164,6 +167,9 @@ const ColumnFilterDropdown = ({
propertyName={propertyName} propertyName={propertyName}
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
filter={filter}
masterFilter={masterFilter}
visible={visible}
/> />
</div> </div>
</ScrollBox> </ScrollBox>
@ -181,7 +187,9 @@ ColumnFilterDropdown.propTypes = {
visible: PropTypes.bool, visible: PropTypes.bool,
propertyName: PropTypes.string, propertyName: PropTypes.string,
propertyLabel: PropTypes.string, propertyLabel: PropTypes.string,
modelType: PropTypes.string modelType: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object
} }
const RowForm = ({ record, isEditing, onRegister, children }) => { const RowForm = ({ record, isEditing, onRegister, children }) => {
@ -867,6 +875,14 @@ const ObjectTable = forwardRef(
return { ...active, ...masterFilter } return { ...active, ...masterFilter }
}, [sidebarFilter, masterFilter]) }, [sidebarFilter, masterFilter])
const tableFilterContextValue = useMemo(
() => ({
filter: getActiveFilterValues(sidebarFilter),
masterFilter
}),
[sidebarFilter, masterFilter]
)
newEventHandlerRef.current = newEventHandler newEventHandlerRef.current = newEventHandler
subscriptionFilterRef.current = subscriptionFilter subscriptionFilterRef.current = subscriptionFilter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
@ -1112,6 +1128,8 @@ const ObjectTable = forwardRef(
propertyName={propertyName} propertyName={propertyName}
propertyLabel={propertyLabel} propertyLabel={propertyLabel}
modelType={type} modelType={type}
filter={sidebarFilter}
masterFilter={masterFilter}
/> />
) )
@ -1583,7 +1601,11 @@ const ObjectTable = forwardRef(
</Flex> </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 { Spin, Tree, Flex, Checkbox, Typography } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -369,31 +369,94 @@ const expandKeysForChecked = (keys, nodeByKey) => {
return [...checked] 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 = ({ const SimpleDateTimePropertyFilter = ({
modelType, modelType,
propertyName, propertyName,
value = [], value = [],
onChange, onChange,
search = '' search = '',
filter = EMPTY_OBJECT,
masterFilter = EMPTY_OBJECT,
visible = true
}) => { }) => {
const { getModelPropertyValues } = useContext(ApiServerContext) const { getModelPropertyValues } = useContext(ApiServerContext)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
getModelPropertyValuesRef.current = getModelPropertyValues
const [dates, setDates] = useState([]) const [dates, setDates] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [localChecked, setLocalChecked] = useState(null) 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(() => { useEffect(() => {
const becameVisible = visible && !wasVisibleRef.current
wasVisibleRef.current = visible
if (!visible) return
if (!becameVisible && loadedFilterHashRef.current != null) return
let cancelled = false let cancelled = false
const loadOptions = async () => { const loadOptions = async () => {
if (!modelType || !propertyName) return if (!modelType || !propertyName) return
const currentHash = filterHashRef.current
if (loadedFilterHashRef.current === currentHash) return
setDates([])
setLoading(true) setLoading(true)
try { try {
const values = await getModelPropertyValues(modelType, propertyName) const values = await getModelPropertyValuesRef.current(
modelType,
propertyName,
{
filter: filterForValuesRef.current,
masterFilter: masterFilterForValuesRef.current
}
)
if (cancelled) return if (cancelled) return
const parsed = (values || []) const parsed = (values || [])
.map(parseDateValue) .map(parseDateValue)
.filter(Boolean) .filter(Boolean)
.sort((a, b) => a.getTime() - b.getTime()) .sort((a, b) => a.getTime() - b.getTime())
loadedFilterHashRef.current = currentHash
setDates(parsed) setDates(parsed)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@ -404,7 +467,7 @@ const SimpleDateTimePropertyFilter = ({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [modelType, propertyName, getModelPropertyValues]) }, [visible, modelType, propertyName])
const treeData = useMemo( const treeData = useMemo(
() => buildTreeData(buildDateHierarchy(dates)), () => buildTreeData(buildDateHierarchy(dates)),
@ -569,7 +632,10 @@ SimpleDateTimePropertyFilter.propTypes = {
propertyName: PropTypes.string, propertyName: PropTypes.string,
value: PropTypes.array, value: PropTypes.array,
onChange: PropTypes.func, onChange: PropTypes.func,
search: PropTypes.string search: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object,
visible: PropTypes.bool
} }
export default SimpleDateTimePropertyFilter 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 { Flex, Checkbox, Spin, Typography } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
import { import {
getModelByName, getModelByName,
getModelProperties getModelProperties
@ -50,27 +51,91 @@ const matchOptions = (options, selected) => {
return options.filter((option) => selectedKeys.has(getOptionKey(option))) 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. // Survive table reloads / popover remounts without refetching the same property.
const optionsCache = new Map() const optionsCache = new Map()
const lastLoadedFilterHashByProperty = new Map()
const EMPTY_OBJECT = {}
const SimplePropertyFilter = ({ const SimplePropertyFilter = ({
modelType, modelType,
propertyName, propertyName,
value = [], value = [],
onChange, onChange,
search = '' search = '',
filter = EMPTY_OBJECT,
masterFilter = EMPTY_OBJECT,
visible = true,
useTableFilter = true
}) => { }) => {
const { getModelPropertyValues } = useContext(ApiServerContext) const { getModelPropertyValues } = useContext(ApiServerContext)
const tableFilterContext = useContext(ObjectTableFilterContext)
const getModelPropertyValuesRef = useRef(getModelPropertyValues) const getModelPropertyValuesRef = useRef(getModelPropertyValues)
getModelPropertyValuesRef.current = 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 = const cacheKey =
modelType && propertyName ? `${modelType}:${propertyName}` : null modelType && propertyName
? `${modelType}:${propertyName}:${filterHash}`
: null
const [options, setOptions] = useState( const [options, setOptions] = useState(
() => (cacheKey && optionsCache.get(cacheKey)) || [] () => (cacheKey && optionsCache.get(cacheKey)) || []
) )
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
() => !(cacheKey && optionsCache.has(cacheKey)) () => visible && !(cacheKey && optionsCache.has(cacheKey))
) )
const [localChecked, setLocalChecked] = useState(null) const [localChecked, setLocalChecked] = useState(null)
@ -81,6 +146,11 @@ const SimplePropertyFilter = ({
) )
useEffect(() => { useEffect(() => {
const becameVisible = visible && !wasVisibleRef.current
wasVisibleRef.current = visible
if (!visible) return
if (!becameVisible && loadedFilterHashRef.current != null) return
let cancelled = false let cancelled = false
const loadOptions = async () => { const loadOptions = async () => {
@ -89,25 +159,40 @@ const SimplePropertyFilter = ({
return return
} }
const key = `${modelType}:${propertyName}` const currentHash = filterHashRef.current
const cached = optionsCache.get(key) const propertyKey = `${modelType}:${propertyName}`
if (cached) { const lastHash =
setOptions(cached) loadedFilterHashRef.current ??
setLoading(false) lastLoadedFilterHashByProperty.get(propertyKey)
return
if (lastHash === currentHash) {
loadedFilterHashRef.current = currentHash
const cached = optionsCache.get(`${propertyKey}:${currentHash}`)
if (cached) {
setOptions(cached)
setLoading(false)
return
}
} }
setOptions([])
setLoading(true) setLoading(true)
try { try {
const values = await getModelPropertyValuesRef.current( const values = await getModelPropertyValuesRef.current(
modelType, modelType,
propertyName propertyName,
{
filter: filterForValuesRef.current,
masterFilter: masterFilterForValuesRef.current
}
) )
if (cancelled) return if (cancelled) return
const unique = [ const unique = [
...new Set((values || []).filter((v) => v != null && v !== '')) ...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) setOptions(unique)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@ -118,9 +203,8 @@ const SimplePropertyFilter = ({
return () => { return () => {
cancelled = true cancelled = true
} }
// Intentionally omit getModelPropertyValues — context recreates it on every // Reload only when this filter becomes visible, then compare filterHash.
// provider render (e.g. table fetchLoading), which would refetch endlessly. }, [visible, modelType, propertyName, property])
}, [modelType, propertyName, property])
const valueKey = useMemo( const valueKey = useMemo(
() => JSON.stringify((value || []).map(getOptionKey)), () => JSON.stringify((value || []).map(getOptionKey)),
@ -179,6 +263,9 @@ const SimplePropertyFilter = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
search={search} search={search}
filter={resolvedFilter}
masterFilter={resolvedMasterFilter}
visible={visible}
/> />
) )
} }
@ -225,7 +312,11 @@ SimplePropertyFilter.propTypes = {
propertyName: PropTypes.string, propertyName: PropTypes.string,
value: PropTypes.array, value: PropTypes.array,
onChange: PropTypes.func, onChange: PropTypes.func,
search: PropTypes.string search: PropTypes.string,
filter: PropTypes.object,
masterFilter: PropTypes.object,
visible: PropTypes.bool,
useTableFilter: PropTypes.bool
} }
export default SimplePropertyFilter 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( logger.debug(
'Fetching property values for model type:', 'Fetching property values for model type:',
objectType, objectType,
property property,
{ filter, masterFilter }
) )
try { try {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/${getObjectEndpoint(objectType)}/values`, `${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: { headers: {
Accept: 'application/json', Accept: 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
@ -1900,7 +1912,7 @@ const ApiServerProvider = ({ children }) => {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
showError(err, () => { showError(err, () => {
getModelPropertyValues(objectType, property) getModelPropertyValues(objectType, property, params)
}) })
return [] return []
} }