Enhance FilterInput and FilterSidebar with property filter support

- Updated FilterInput to include property filter functionality, allowing for more complex filtering expressions.
- Introduced new utility functions to convert between filter expressions and values, improving data handling.
- Modified FilterSidebar to pass property filter configurations to the FilterInput, enhancing the filtering capabilities.
- Improved SimplePropertyFilter to cache options and prevent unnecessary refetching, optimizing performance during interactions.
This commit is contained in:
Tom Butcher 2026-08-11 00:45:45 +01:00
parent 6b848d392c
commit 18ac93d8e9
3 changed files with 279 additions and 148 deletions

View File

@ -1,5 +1,5 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { ConfigProvider, Tag, theme } from 'antd'
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { ConfigProvider, Popover, Tag, theme } from 'antd'
import { CloseCircleFilled } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { createRoot } from 'react-dom/client'
@ -9,6 +9,8 @@ import useSize from 'antd/es/config-provider/hooks/useSize'
import { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext'
import SimplePropertyFilter from './SimplePropertyFilter'
import ScrollBox from './ScrollBox'
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) and @ stay as plain text they are not operands.
@ -327,6 +329,25 @@ const chipsHaveLiveRoots = (root, roots) => {
return true
}
const toFilterExpression = (values) => {
if (!values?.length) return ''
const parts = values.map((value) => {
if (value && typeof value === 'object') {
return String(value._id ?? value.type ?? JSON.stringify(value))
}
return String(value)
})
return parts.length === 1 ? parts[0] : parts.join('|')
}
const fromFilterExpression = (expr) => {
if (expr === undefined || expr === null || expr === '') return []
if (typeof expr === 'string' && expr.includes('|')) {
return expr.split('|').filter((part) => part !== '')
}
return [expr]
}
const FilterInput = ({
value = '',
onChange,
@ -338,7 +359,8 @@ const FilterInput = ({
onFocus,
onBlur,
onPressEnter,
size
size,
propertyFilter = null
}) => {
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
const prefixCls = getPrefixCls('input')
@ -734,6 +756,46 @@ const FilterInput = ({
editorRef.current?.focus()
}
const propertyFilterEnabled =
!disabled &&
propertyFilter?.modelType != null &&
propertyFilter?.propertyName != null
const propertyFilterValue = useMemo(
() => fromFilterExpression(internalValue),
[internalValue]
)
const handlePropertyFilterChange = useCallback(
(keys) => {
const next = toFilterExpression(keys)
paint(next, focusedRef.current ? next.length : null)
emitChange(next)
},
[emitChange, paint]
)
const propertyFilterContent = propertyFilterEnabled ? (
<div
onMouseDown={(event) => {
// Keep the input focused while interacting with the popover.
event.preventDefault()
}}
style={{ width: 280, height: 220, margin: -4 }}
>
<ScrollBox inner smallPadding>
<div style={{ padding: '12px 16px', minWidth: 0 }}>
<SimplePropertyFilter
modelType={propertyFilter.modelType}
propertyName={propertyFilter.propertyName}
value={propertyFilterValue}
onChange={handlePropertyFilterChange}
/>
</div>
</ScrollBox>
</div>
) : null
// Auto-scroll while click-dragging a selection past the visible edges.
useEffect(() => {
let raf = 0
@ -828,8 +890,7 @@ const FilterInput = ({
? (token.paddingInlineSM ?? token.paddingXS)
: (token.paddingInline ?? token.paddingSM)
return wrapSharedCSSVar(
wrapCSSVar(
const input = (
<span
className={classNames(
affixCls,
@ -969,6 +1030,28 @@ const FilterInput = ({
)}
</span>
)
return wrapSharedCSSVar(
wrapCSSVar(
propertyFilterEnabled ? (
<Popover
open={focused}
content={propertyFilterContent}
placement='bottomLeft'
arrow={false}
trigger={[]}
styles={{
body: {
padding: 8
}
}}
>
{input}
</Popover>
) : (
input
)
)
)
}
@ -983,7 +1066,11 @@ FilterInput.propTypes = {
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onPressEnter: PropTypes.func,
size: PropTypes.oneOf(['small', 'middle', 'large'])
size: PropTypes.oneOf(['small', 'middle', 'large']),
propertyFilter: PropTypes.shape({
modelType: PropTypes.string.isRequired,
propertyName: PropTypes.string.isRequired
})
}
export default FilterInput

View File

@ -39,7 +39,17 @@ const FilterSidebar = ({
if (initialEmptyFields.current.has(k) && v === '') continue
visible[k] = v
}
setLocalFilter(visible)
setLocalFilter((prev) => {
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(visible)
if (
prevKeys.length === nextKeys.length &&
nextKeys.every((key) => prev[key] === visible[key])
) {
return prev
}
return visible
})
}, [filter])
const debouncedFilterChange = useCallback(
@ -167,6 +177,10 @@ const FilterSidebar = ({
value={row.value}
onChange={(value) => changeValue(row.field, value)}
style={{ flex: 1 }}
propertyFilter={{
modelType: type,
propertyName: row.field
}}
/>
<Button
icon={<CloseOutlined />}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useMemo } from 'react'
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'
@ -45,6 +45,9 @@ const matchOptions = (options, selected) => {
return options.filter((option) => selectedKeys.has(getOptionKey(option)))
}
// Survive table reloads / popover remounts without refetching the same property.
const optionsCache = new Map()
const SimplePropertyFilter = ({
modelType,
propertyName,
@ -53,8 +56,17 @@ const SimplePropertyFilter = ({
search = ''
}) => {
const { getModelPropertyValues } = useContext(ApiServerContext)
const [options, setOptions] = useState([])
const [loading, setLoading] = useState(false)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
getModelPropertyValuesRef.current = getModelPropertyValues
const cacheKey =
modelType && propertyName ? `${modelType}:${propertyName}` : null
const [options, setOptions] = useState(
() => (cacheKey && optionsCache.get(cacheKey)) || []
)
const [loading, setLoading] = useState(
() => !(cacheKey && optionsCache.has(cacheKey))
)
const [localChecked, setLocalChecked] = useState(null)
const property = useMemo(
@ -67,14 +79,30 @@ const SimplePropertyFilter = ({
let cancelled = false
const loadOptions = async () => {
if (!modelType || !propertyName || isDateTimeProperty(property)) return
if (!modelType || !propertyName || isDateTimeProperty(property)) {
setLoading(false)
return
}
const key = `${modelType}:${propertyName}`
const cached = optionsCache.get(key)
if (cached) {
setOptions(cached)
setLoading(false)
return
}
setLoading(true)
try {
const values = await getModelPropertyValues(modelType, propertyName)
const values = await getModelPropertyValuesRef.current(
modelType,
propertyName
)
if (cancelled) return
const unique = [
...new Set((values || []).filter((v) => v != null && v !== ''))
]
optionsCache.set(key, unique)
setOptions(unique)
} finally {
if (!cancelled) setLoading(false)
@ -85,7 +113,9 @@ const SimplePropertyFilter = ({
return () => {
cancelled = true
}
}, [modelType, propertyName, getModelPropertyValues, property])
// Intentionally omit getModelPropertyValues context recreates it on every
// provider render (e.g. table fetchLoading), which would refetch endlessly.
}, [modelType, propertyName, property])
const valueKey = useMemo(
() => JSON.stringify((value || []).map(getOptionKey)),