Compare commits

...

4 Commits

Author SHA1 Message Date
72c5ee1b91 Enhance FilterSidebar with dynamic field select sizing
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Introduced a new FieldSelectLabel component to dynamically size select fields based on the longest label, improving layout consistency.
- Updated FilterSidebar to utilize the FieldSelectLabel for better visual alignment of select options.
- Adjusted CSS styles for the filter sidebar field selects to ensure proper width handling.
2026-08-11 01:03:40 +01:00
ca74cce3b9 Enhance FilterInput component with improved positioning and accessibility
- Added relative positioning and z-index management to ensure the focus ring is visible above Compact siblings.
- Refactored Popover anchor to prevent wrapping of Compact items, enhancing user interaction and accessibility.
- Cleaned up JSX structure for better readability and maintainability.
2026-08-11 00:55:58 +01:00
198b1c2244 Update SimpleDateTimePropertyFilter styles and JSX structure
- Added a transparent background color to the .simple-date-time-property-filter class for improved visual integration.
- Adjusted padding for the Text component within SimpleDateTimePropertyFilter to enhance layout consistency and spacing.
2026-08-11 00:51:55 +01:00
18ac93d8e9 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.
2026-08-11 00:45:45 +01:00
5 changed files with 330 additions and 145 deletions

View File

@ -1109,6 +1109,7 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.simple-date-time-property-filter { .simple-date-time-property-filter {
margin: -6px 0; margin: -6px 0;
background-color: transparent;
} }
.simple-date-time-property-filter.ant-tree .ant-tree-treenode { .simple-date-time-property-filter.ant-tree .ant-tree-treenode {
@ -1130,3 +1131,11 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
top: -16px !important; top: -16px !important;
bottom: -24px !important; bottom: -24px !important;
} }
/* Size FilterSidebar field selects to the longest label (via labelRender sizer). */
.filter-sidebar-field-select.ant-select {
width: max-content;
}
.filter-sidebar-field-select .ant-select-selector {
width: max-content !important;
}

View File

@ -1,5 +1,5 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { ConfigProvider, Tag, theme } from 'antd' import { ConfigProvider, Popover, Tag, theme } from 'antd'
import { CloseCircleFilled } from '@ant-design/icons' import { CloseCircleFilled } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { createRoot } from 'react-dom/client' 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 { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style' import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import SimplePropertyFilter from './SimplePropertyFilter'
import ScrollBox from './ScrollBox'
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">". // Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) and @ stay as plain text — they are not operands. // Wildcards (* ?) and @ stay as plain text — they are not operands.
@ -327,6 +329,25 @@ const chipsHaveLiveRoots = (root, roots) => {
return true 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 = ({ const FilterInput = ({
value = '', value = '',
onChange, onChange,
@ -338,7 +359,8 @@ const FilterInput = ({
onFocus, onFocus,
onBlur, onBlur,
onPressEnter, onPressEnter,
size size,
propertyFilter = null
}) => { }) => {
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext) const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
const prefixCls = getPrefixCls('input') const prefixCls = getPrefixCls('input')
@ -734,6 +756,46 @@ const FilterInput = ({
editorRef.current?.focus() 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. // Auto-scroll while click-dragging a selection past the visible edges.
useEffect(() => { useEffect(() => {
let raf = 0 let raf = 0
@ -828,8 +890,7 @@ const FilterInput = ({
? (token.paddingInlineSM ?? token.paddingXS) ? (token.paddingInlineSM ?? token.paddingXS)
: (token.paddingInline ?? token.paddingSM) : (token.paddingInline ?? token.paddingSM)
return wrapSharedCSSVar( const input = (
wrapCSSVar(
<span <span
className={classNames( className={classNames(
affixCls, affixCls,
@ -852,7 +913,10 @@ const FilterInput = ({
cursor: disabled ? 'not-allowed' : 'text', cursor: disabled ? 'not-allowed' : 'text',
...style, ...style,
// Affix ::before strut needs inline-flex; display:block stacks it and inflates height. // Affix ::before strut needs inline-flex; display:block stacks it and inflates height.
display: 'inline-flex' display: 'inline-flex',
// Stay above Compact siblings so the focus ring isn't covered by Select/Button.
position: 'relative',
zIndex: focused ? 3 : style?.zIndex
}} }}
onClick={() => { onClick={() => {
if (!disabled) editorRef.current?.focus() if (!disabled) editorRef.current?.focus()
@ -967,9 +1031,37 @@ const FilterInput = ({
</span> </span>
</span> </span>
)} )}
{propertyFilterEnabled && (
<Popover
open={focused}
content={propertyFilterContent}
placement='bottomLeft'
arrow={false}
trigger={[]}
styles={{
body: {
padding: 8
}
}}
>
{/* Anchor only — keep Popover from wrapping the Compact item (z-index/focus ring). */}
<span
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 0,
pointerEvents: 'none'
}}
/>
</Popover>
)}
</span> </span>
) )
)
return wrapSharedCSSVar(wrapCSSVar(input))
} }
FilterInput.propTypes = { FilterInput.propTypes = {
@ -983,7 +1075,11 @@ FilterInput.propTypes = {
onFocus: PropTypes.func, onFocus: PropTypes.func,
onBlur: PropTypes.func, onBlur: PropTypes.func,
onPressEnter: 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 export default FilterInput

View File

@ -11,6 +11,36 @@ import {
import MissingPlaceholder from './MissingPlaceholder' import MissingPlaceholder from './MissingPlaceholder'
import BinIcon from '../../Icons/BinIcon' import BinIcon from '../../Icons/BinIcon'
const hiddenSizerStyle = {
gridArea: '1 / 1',
visibility: 'hidden',
whiteSpace: 'nowrap'
}
const FieldSelectLabel = ({ label, options }) => (
<span style={{ display: 'inline-grid' }}>
<span aria-hidden style={hiddenSizerStyle}>
Field
</span>
{options.map((option) => (
<span key={option.value} aria-hidden style={hiddenSizerStyle}>
{option.label}
</span>
))}
<span style={{ gridArea: '1 / 1', whiteSpace: 'nowrap' }}>{label}</span>
</span>
)
FieldSelectLabel.propTypes = {
label: PropTypes.node,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.string
})
).isRequired
}
const FilterSidebar = ({ const FilterSidebar = ({
type, type,
filter = {}, filter = {},
@ -39,7 +69,17 @@ const FilterSidebar = ({
if (initialEmptyFields.current.has(k) && v === '') continue if (initialEmptyFields.current.has(k) && v === '') continue
visible[k] = v 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]) }, [filter])
const debouncedFilterChange = useCallback( const debouncedFilterChange = useCallback(
@ -159,14 +199,22 @@ const FilterSidebar = ({
value={row.field || undefined} value={row.field || undefined}
onChange={(v) => changeField(row.field, v)} onChange={(v) => changeField(row.field, v)}
options={availableOptions(row.field)} options={availableOptions(row.field)}
style={{ minWidth: 80 }}
allowClear={false} allowClear={false}
labelRender={({ label }) => (
<FieldSelectLabel label={label} options={fieldOptions} />
)}
style={{ flexShrink: 0 }}
classNames={{ root: 'filter-sidebar-field-select' }}
/> />
<FilterInput <FilterInput
placeholder='Value' placeholder='Value'
value={row.value} value={row.value}
onChange={(value) => changeValue(row.field, value)} onChange={(value) => changeValue(row.field, value)}
style={{ flex: 1 }} style={{ flex: 1 }}
propertyFilter={{
modelType: type,
propertyName: row.field
}}
/> />
<Button <Button
icon={<CloseOutlined />} icon={<CloseOutlined />}

View File

@ -539,7 +539,9 @@ const SimpleDateTimePropertyFilter = ({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ minWidth: 0 }} style={{ minWidth: 0 }}
/> />
<Text style={{ minWidth: 0 }}>{node.title}</Text> <Text style={{ minWidth: 0, paddingInlineStart: '8px' }}>
{node.title}
</Text>
</Flex> </Flex>
) )
} }

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 { 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'
@ -45,6 +45,9 @@ const matchOptions = (options, selected) => {
return options.filter((option) => selectedKeys.has(getOptionKey(option))) return options.filter((option) => selectedKeys.has(getOptionKey(option)))
} }
// Survive table reloads / popover remounts without refetching the same property.
const optionsCache = new Map()
const SimplePropertyFilter = ({ const SimplePropertyFilter = ({
modelType, modelType,
propertyName, propertyName,
@ -53,8 +56,17 @@ const SimplePropertyFilter = ({
search = '' search = ''
}) => { }) => {
const { getModelPropertyValues } = useContext(ApiServerContext) const { getModelPropertyValues } = useContext(ApiServerContext)
const [options, setOptions] = useState([]) const getModelPropertyValuesRef = useRef(getModelPropertyValues)
const [loading, setLoading] = useState(false) 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 [localChecked, setLocalChecked] = useState(null)
const property = useMemo( const property = useMemo(
@ -67,14 +79,30 @@ const SimplePropertyFilter = ({
let cancelled = false let cancelled = false
const loadOptions = async () => { 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) setLoading(true)
try { try {
const values = await getModelPropertyValues(modelType, propertyName) const values = await getModelPropertyValuesRef.current(
modelType,
propertyName
)
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)
setOptions(unique) setOptions(unique)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@ -85,7 +113,9 @@ const SimplePropertyFilter = ({
return () => { return () => {
cancelled = true 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( const valueKey = useMemo(
() => JSON.stringify((value || []).map(getOptionKey)), () => JSON.stringify((value || []).map(getOptionKey)),