diff --git a/src/components/Dashboard/common/FilterInput.jsx b/src/components/Dashboard/common/FilterInput.jsx index 9e2aaa0..c2ba552 100644 --- a/src/components/Dashboard/common/FilterInput.jsx +++ b/src/components/Dashboard/common/FilterInput.jsx @@ -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 ? ( +
{ + // Keep the input focused while interacting with the popover. + event.preventDefault() + }} + style={{ width: 280, height: 220, margin: -4 }} + > + +
+ +
+
+
+ ) : null + // Auto-scroll while click-dragging a selection past the visible edges. useEffect(() => { let raf = 0 @@ -828,146 +890,167 @@ const FilterInput = ({ ? (token.paddingInlineSM ?? token.paddingXS) : (token.paddingInline ?? token.paddingSM) + const input = ( + { + if (!disabled) editorRef.current?.focus() + }} + > + + {showPlaceholder && ( + + {placeholder} + + )} +
{ + selectingRef.current = true + }} + onCompositionStart={() => { + composingRef.current = true + }} + onCompositionEnd={() => { + composingRef.current = false + syncFromDom() + }} + onFocus={(event) => { + focusedRef.current = true + setFocused(true) + onFocus?.(event) + }} + onBlur={(event) => { + focusedRef.current = false + selectingRef.current = false + setFocused(false) + const next = normalize(serializeNode(editorRef.current)) + paint(next) + if (next !== valueRef.current) emitChange(next) + onBlur?.(event) + }} + style={{ + // Match antd's `> input.ant-input` resets inside affix-wrapper + display: 'block', + padding: 0, + border: 'none', + borderRadius: 0, + outline: 'none', + boxShadow: 'none', + background: 'transparent', + flex: 'auto', + minWidth: 0, + width: '100%', + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'inherit', + whiteSpace: 'nowrap', + overflowX: 'auto', + overflowY: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + wordBreak: 'keep-all', + overflowWrap: 'normal', + caretColor: token.colorText, + cursor: disabled ? 'not-allowed' : 'text' + }} + /> + {showClear && ( + + + + + + )} + + ) + return wrapSharedCSSVar( wrapCSSVar( - { - if (!disabled) editorRef.current?.focus() - }} - > - - {showPlaceholder && ( - - {placeholder} - - )} -
{ - selectingRef.current = true + propertyFilterEnabled ? ( + { - composingRef.current = true - }} - onCompositionEnd={() => { - composingRef.current = false - syncFromDom() - }} - onFocus={(event) => { - focusedRef.current = true - setFocused(true) - onFocus?.(event) - }} - onBlur={(event) => { - focusedRef.current = false - selectingRef.current = false - setFocused(false) - const next = normalize(serializeNode(editorRef.current)) - paint(next) - if (next !== valueRef.current) emitChange(next) - onBlur?.(event) - }} - style={{ - // Match antd's `> input.ant-input` resets inside affix-wrapper - display: 'block', - padding: 0, - border: 'none', - borderRadius: 0, - outline: 'none', - boxShadow: 'none', - background: 'transparent', - flex: 'auto', - minWidth: 0, - width: '100%', - fontSize: 'inherit', - lineHeight: 'inherit', - color: 'inherit', - whiteSpace: 'nowrap', - overflowX: 'auto', - overflowY: 'hidden', - scrollbarWidth: 'none', - msOverflowStyle: 'none', - wordBreak: 'keep-all', - overflowWrap: 'normal', - caretColor: token.colorText, - cursor: disabled ? 'not-allowed' : 'text' - }} - /> - {showClear && ( - - - - - - )} - + > + {input} + + ) : ( + 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 diff --git a/src/components/Dashboard/common/FilterSidebar.jsx b/src/components/Dashboard/common/FilterSidebar.jsx index 11ecb92..cf7d8cc 100644 --- a/src/components/Dashboard/common/FilterSidebar.jsx +++ b/src/components/Dashboard/common/FilterSidebar.jsx @@ -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 + }} />