Add FilterInput component for enhanced filtering capabilities
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced a new FilterInput component to streamline user input for filter values, replacing the standard Input component in FilterSidebar and ObjectTable. - Updated FilterSidebar and ObjectTable to utilize FilterInput, improving the overall filtering experience and consistency across the dashboard. - Enhanced expression handling in ColumnFilterDropdown to accommodate the new FilterInput component, ensuring seamless integration with existing functionality.
This commit is contained in:
parent
fdd7a09d84
commit
6b848d392c
990
src/components/Dashboard/common/FilterInput.jsx
Normal file
990
src/components/Dashboard/common/FilterInput.jsx
Normal file
@ -0,0 +1,990 @@
|
|||||||
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ConfigProvider, Tag, theme } from 'antd'
|
||||||
|
import { CloseCircleFilled } from '@ant-design/icons'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import useCSSVarCls from 'antd/es/config-provider/hooks/useCSSVarCls'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
|
||||||
|
// Wildcards (* ?) and @ stay as plain text — they are not operands.
|
||||||
|
const OPERANDS = [
|
||||||
|
{ symbol: '..', label: 'TO', color: 'cyan' },
|
||||||
|
{ symbol: '<>', label: '≠', color: 'volcano' },
|
||||||
|
{ symbol: '>=', label: '> OR =', color: 'orange' },
|
||||||
|
{ symbol: '<=', label: '< OR =', color: 'orange' },
|
||||||
|
{ symbol: '|', label: 'OR', color: 'purple' },
|
||||||
|
{ symbol: '&', label: 'AND', color: 'purple' },
|
||||||
|
{ symbol: '>', label: '>', color: 'orange' },
|
||||||
|
{ symbol: '<', label: '<', color: 'orange' },
|
||||||
|
{ symbol: '=', label: '=', color: 'blue' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPERAND_BY_SYMBOL = Object.fromEntries(
|
||||||
|
OPERANDS.map((operand) => [operand.symbol, operand])
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenize = (raw = '') => {
|
||||||
|
const value = String(raw)
|
||||||
|
const tokens = []
|
||||||
|
let text = ''
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < value.length) {
|
||||||
|
const match = OPERANDS.find((operand) =>
|
||||||
|
value.startsWith(operand.symbol, i)
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
if (text) {
|
||||||
|
tokens.push({ type: 'text', value: text })
|
||||||
|
text = ''
|
||||||
|
}
|
||||||
|
tokens.push({ type: 'operand', value: match.symbol })
|
||||||
|
i += match.symbol.length
|
||||||
|
} else {
|
||||||
|
text += value[i]
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) tokens.push({ type: 'text', value: text })
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (raw = '') =>
|
||||||
|
tokenize(raw)
|
||||||
|
.map((token) => token.value)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const nodeLength = (node) => {
|
||||||
|
if (!node) return 0
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0
|
||||||
|
const operand = node.getAttribute?.('data-operand')
|
||||||
|
if (operand != null) return operand.length
|
||||||
|
return Array.from(node.childNodes).reduce(
|
||||||
|
(sum, child) => sum + nodeLength(child),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeNode = (root) => {
|
||||||
|
if (!root) return ''
|
||||||
|
let result = ''
|
||||||
|
for (const node of root.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
result += node.textContent ?? ''
|
||||||
|
} else if (node.getAttribute?.('data-operand') != null) {
|
||||||
|
result += node.getAttribute('data-operand')
|
||||||
|
} else {
|
||||||
|
result += serializeNode(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Character offset in the serialized filter string for a DOM point. */
|
||||||
|
const getOffsetAt = (root, targetNode, targetOffset, chipSnap = 'end') => {
|
||||||
|
if (!root || !targetNode || !root.contains(targetNode)) {
|
||||||
|
return serializeNode(root).length
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
const visit = (node) => {
|
||||||
|
if (node === targetNode) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
offset += targetOffset
|
||||||
|
} else if (node.getAttribute?.('data-operand') != null) {
|
||||||
|
offset += targetOffset <= 0 ? 0 : node.getAttribute('data-operand').length
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < targetOffset; i += 1) {
|
||||||
|
offset += nodeLength(node.childNodes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
offset += node.textContent?.length ?? 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.getAttribute?.('data-operand') != null) {
|
||||||
|
if (node.contains(targetNode)) {
|
||||||
|
// Chips are atomic: snap range starts to the chip start, ends to the chip end.
|
||||||
|
if (chipSnap !== 'start') {
|
||||||
|
offset += node.getAttribute('data-operand').length
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
offset += node.getAttribute('data-operand').length
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (visit(child)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(root)
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Character offset of the selection focus (caret). */
|
||||||
|
const getCaretOffset = (root) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !root.contains(selection.focusNode)) {
|
||||||
|
return serializeNode(root).length
|
||||||
|
}
|
||||||
|
return getOffsetAt(root, selection.focusNode, selection.focusOffset, 'end')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized filter text for the current selection (operand symbols, not labels). */
|
||||||
|
const getSelectedSerializedText = (root) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || selection.isCollapsed) return ''
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (
|
||||||
|
!root.contains(range.startContainer) ||
|
||||||
|
!root.contains(range.endContainer)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = getOffsetAt(
|
||||||
|
root,
|
||||||
|
range.startContainer,
|
||||||
|
range.startOffset,
|
||||||
|
'start'
|
||||||
|
)
|
||||||
|
const end = getOffsetAt(root, range.endContainer, range.endOffset, 'end')
|
||||||
|
return serializeNode(root).slice(Math.min(start, end), Math.max(start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the caret / selection focus visible inside a horizontally clipped editor. */
|
||||||
|
const scrollPointIntoView = (root, clientX) => {
|
||||||
|
if (!root || clientX == null || Number.isNaN(clientX)) return
|
||||||
|
const rect = root.getBoundingClientRect()
|
||||||
|
const pad = 8
|
||||||
|
if (clientX > rect.right - pad) {
|
||||||
|
root.scrollLeft += clientX - (rect.right - pad)
|
||||||
|
} else if (clientX < rect.left + pad) {
|
||||||
|
root.scrollLeft -= rect.left + pad - clientX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollSelectionIntoView = (root) => {
|
||||||
|
if (!root) return
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !root.contains(selection.focusNode)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(selection.focusNode, selection.focusOffset)
|
||||||
|
range.collapse(true)
|
||||||
|
const rects = range.getClientRects()
|
||||||
|
const rect = rects[rects.length - 1] ?? range.getBoundingClientRect()
|
||||||
|
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||||
|
scrollPointIntoView(root, rect.left)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to caret-at-end heuristic.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty / collapsed-at-end selections often have a zero rect — scroll to end.
|
||||||
|
if (getCaretOffset(root) >= serializeNode(root).length) {
|
||||||
|
root.scrollLeft = root.scrollWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const caretRangeFromPoint = (clientX, clientY) => {
|
||||||
|
if (document.caretRangeFromPoint) {
|
||||||
|
return document.caretRangeFromPoint(clientX, clientY)
|
||||||
|
}
|
||||||
|
if (document.caretPositionFromPoint) {
|
||||||
|
const pos = document.caretPositionFromPoint(clientX, clientY)
|
||||||
|
if (!pos) return null
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(pos.offsetNode, pos.offset)
|
||||||
|
range.collapse(true)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extend the live selection to the DOM point under the pointer (used while edge-scrolling). */
|
||||||
|
const extendSelectionToPoint = (root, clientX, clientY) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !root) return
|
||||||
|
const caret = caretRangeFromPoint(clientX, clientY)
|
||||||
|
if (!caret || !root.contains(caret.startContainer)) return
|
||||||
|
try {
|
||||||
|
selection.setBaseAndExtent(
|
||||||
|
selection.anchorNode,
|
||||||
|
selection.anchorOffset,
|
||||||
|
caret.startContainer,
|
||||||
|
caret.startOffset
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid selection boundaries (e.g. inside non-editable chips).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCaretOffset = (root, targetOffset) => {
|
||||||
|
if (!root) return
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
let remaining = Math.max(0, targetOffset)
|
||||||
|
|
||||||
|
const place = (node, offset) => {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, offset)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
scrollSelectionIntoView(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < root.childNodes.length; i += 1) {
|
||||||
|
const child = root.childNodes[i]
|
||||||
|
const length = nodeLength(child)
|
||||||
|
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (remaining <= length) {
|
||||||
|
place(child, remaining)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining -= length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.getAttribute?.('data-operand') != null) {
|
||||||
|
if (remaining === 0) {
|
||||||
|
place(root, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (remaining <= length) {
|
||||||
|
place(root, i + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining -= length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(root)
|
||||||
|
range.collapse(false)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
scrollSelectionIntoView(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the DOM already mirrors tokenize(serialized). */
|
||||||
|
const domMatchesTokens = (root, raw) => {
|
||||||
|
const tokens = tokenize(raw)
|
||||||
|
const children = Array.from(root.childNodes).filter((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== ''
|
||||||
|
return node.getAttribute?.('data-operand') != null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Empty editor may keep a single empty text node.
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return (
|
||||||
|
root.childNodes.length === 0 ||
|
||||||
|
(root.childNodes.length === 1 &&
|
||||||
|
root.childNodes[0].nodeType === Node.TEXT_NODE &&
|
||||||
|
(root.childNodes[0].textContent ?? '') === '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length !== tokens.length) return false
|
||||||
|
|
||||||
|
return tokens.every((token, index) => {
|
||||||
|
const child = children[index]
|
||||||
|
if (token.type === 'text') {
|
||||||
|
return (
|
||||||
|
child.nodeType === Node.TEXT_NODE && child.textContent === token.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return child.getAttribute?.('data-operand') === token.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operand chips are only "live" when their portal React root is still mounted. */
|
||||||
|
const chipsHaveLiveRoots = (root, roots) => {
|
||||||
|
for (const node of root.childNodes) {
|
||||||
|
if (node.getAttribute?.('data-operand') == null) continue
|
||||||
|
if (!roots.has(node)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterInput = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Filter…',
|
||||||
|
disabled = false,
|
||||||
|
allowClear = false,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onPressEnter,
|
||||||
|
size
|
||||||
|
}) => {
|
||||||
|
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
|
||||||
|
const prefixCls = getPrefixCls('input')
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
const { themeConfig } = useThemeContext()
|
||||||
|
const themeConfigRef = useRef(themeConfig)
|
||||||
|
themeConfigRef.current = themeConfig
|
||||||
|
|
||||||
|
const rootCls = useCSSVarCls(prefixCls)
|
||||||
|
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls)
|
||||||
|
const [wrapCSSVar] = useStyle(prefixCls, rootCls)
|
||||||
|
|
||||||
|
const { compactSize, compactItemClassnames } = useCompactItemContext(
|
||||||
|
prefixCls,
|
||||||
|
direction
|
||||||
|
)
|
||||||
|
const mergedSize = useSize((ctx) => size ?? compactSize ?? ctx)
|
||||||
|
|
||||||
|
const editorRef = useRef(null)
|
||||||
|
const tagRootsRef = useRef(new Map())
|
||||||
|
const focusedRef = useRef(false)
|
||||||
|
const composingRef = useRef(false)
|
||||||
|
const selectingRef = useRef(false)
|
||||||
|
const valueRef = useRef(value ?? '')
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const [internalValue, setInternalValue] = useState(value ?? '')
|
||||||
|
|
||||||
|
const clearTagRoots = useCallback(() => {
|
||||||
|
tagRootsRef.current.forEach((root) => {
|
||||||
|
queueMicrotask(() => root.unmount())
|
||||||
|
})
|
||||||
|
tagRootsRef.current.clear()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const unmountChip = useCallback((wrapper) => {
|
||||||
|
const root = tagRootsRef.current.get(wrapper)
|
||||||
|
if (root) {
|
||||||
|
queueMicrotask(() => root.unmount())
|
||||||
|
tagRootsRef.current.delete(wrapper)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderTagNode = useCallback((symbol, themeValue) => {
|
||||||
|
const operand = OPERAND_BY_SYMBOL[symbol]
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={themeValue}>
|
||||||
|
<Tag
|
||||||
|
color={operand?.color ?? 'default'}
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
marginInlineEnd: 0,
|
||||||
|
marginRight: 0,
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'default',
|
||||||
|
padding: '0 4px',
|
||||||
|
marginTop: '-2px',
|
||||||
|
lineHeight: 1.37,
|
||||||
|
userSelect: 'none',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{operand?.label ?? symbol}
|
||||||
|
</Tag>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderOperandChip = useCallback(
|
||||||
|
(symbol) => {
|
||||||
|
const wrapper = document.createElement('span')
|
||||||
|
wrapper.setAttribute('data-operand', symbol)
|
||||||
|
wrapper.setAttribute('contenteditable', 'false')
|
||||||
|
wrapper.style.display = 'inline-flex'
|
||||||
|
wrapper.style.alignItems = 'center'
|
||||||
|
wrapper.style.verticalAlign = 'middle'
|
||||||
|
wrapper.style.margin = '0 2px'
|
||||||
|
wrapper.style.userSelect = 'none'
|
||||||
|
wrapper.style.flexShrink = '0'
|
||||||
|
|
||||||
|
const root = createRoot(wrapper)
|
||||||
|
root.render(renderTagNode(symbol, themeConfigRef.current))
|
||||||
|
tagRootsRef.current.set(wrapper, root)
|
||||||
|
return wrapper
|
||||||
|
},
|
||||||
|
[renderTagNode]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Portal Tags sit outside the React tree — re-render them when theme changes.
|
||||||
|
useEffect(() => {
|
||||||
|
tagRootsRef.current.forEach((root, wrapper) => {
|
||||||
|
root.render(
|
||||||
|
renderTagNode(wrapper.getAttribute('data-operand'), themeConfig)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [themeConfig, renderTagNode])
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(next) => {
|
||||||
|
valueRef.current = next
|
||||||
|
setInternalValue(next)
|
||||||
|
onChange?.(next)
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const paint = useCallback(
|
||||||
|
(raw, caret = null) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const normalized = normalize(raw)
|
||||||
|
|
||||||
|
// Already in sync with live chip roots — skip to avoid a tag flash.
|
||||||
|
if (
|
||||||
|
domMatchesTokens(editor, normalized) &&
|
||||||
|
chipsHaveLiveRoots(editor, tagRootsRef.current)
|
||||||
|
) {
|
||||||
|
if (caret != null && focusedRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCaretOffset(editor, caret)
|
||||||
|
scrollSelectionIntoView(editor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollLeft = editor.scrollLeft
|
||||||
|
const tokens = tokenize(normalized)
|
||||||
|
|
||||||
|
// Reuse operand chips that still have a mounted React root.
|
||||||
|
const chipPool = new Map()
|
||||||
|
for (const node of Array.from(editor.childNodes)) {
|
||||||
|
const symbol = node.getAttribute?.('data-operand')
|
||||||
|
if (symbol == null) continue
|
||||||
|
if (!tagRootsRef.current.has(node)) continue
|
||||||
|
const list = chipPool.get(symbol)
|
||||||
|
if (list) list.push(node)
|
||||||
|
else chipPool.set(symbol, [node])
|
||||||
|
}
|
||||||
|
|
||||||
|
const takeChip = (symbol) => {
|
||||||
|
const list = chipPool.get(symbol)
|
||||||
|
while (list?.length) {
|
||||||
|
const wrapper = list.shift()
|
||||||
|
if (tagRootsRef.current.has(wrapper)) return wrapper
|
||||||
|
}
|
||||||
|
return renderOperandChip(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChildren = []
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
nextChildren.push(document.createTextNode(''))
|
||||||
|
} else {
|
||||||
|
for (const item of tokens) {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
nextChildren.push(document.createTextNode(item.value))
|
||||||
|
} else {
|
||||||
|
nextChildren.push(takeChip(item.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chipPool.forEach((list) => {
|
||||||
|
list.forEach((wrapper) => unmountChip(wrapper))
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.replaceChildren(...nextChildren)
|
||||||
|
editor.scrollLeft = scrollLeft
|
||||||
|
|
||||||
|
if (caret != null && focusedRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCaretOffset(editor, caret)
|
||||||
|
scrollSelectionIntoView(editor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[renderOperandChip, unmountChip]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = value ?? ''
|
||||||
|
valueRef.current = next
|
||||||
|
setInternalValue(next)
|
||||||
|
if (!focusedRef.current) {
|
||||||
|
paint(next)
|
||||||
|
}
|
||||||
|
}, [value, paint])
|
||||||
|
|
||||||
|
useEffect(() => () => clearTagRoots(), [clearTagRoots])
|
||||||
|
|
||||||
|
const syncFromDom = useCallback(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const caret = getCaretOffset(editor)
|
||||||
|
const serialized = serializeNode(editor)
|
||||||
|
const normalized = normalize(serialized)
|
||||||
|
|
||||||
|
if (!domMatchesTokens(editor, normalized)) {
|
||||||
|
paint(normalized, caret)
|
||||||
|
} else {
|
||||||
|
scrollSelectionIntoView(editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized !== valueRef.current) {
|
||||||
|
emitChange(normalized)
|
||||||
|
}
|
||||||
|
}, [emitChange, paint])
|
||||||
|
|
||||||
|
const findOperandWrapper = (node) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
let current = node
|
||||||
|
while (current && current !== editor) {
|
||||||
|
if (current.getAttribute?.('data-operand') != null) return current
|
||||||
|
current = current.parentNode
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
if (composingRef.current || disabled) return
|
||||||
|
syncFromDom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeChip = (chip) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor || !chip) return
|
||||||
|
const parent = chip.parentNode
|
||||||
|
const index = Array.from(parent.childNodes).indexOf(chip)
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < index; i += 1) {
|
||||||
|
offset += nodeLength(parent.childNodes[i])
|
||||||
|
}
|
||||||
|
const root = tagRootsRef.current.get(chip)
|
||||||
|
if (root) {
|
||||||
|
queueMicrotask(() => root.unmount())
|
||||||
|
tagRootsRef.current.delete(chip)
|
||||||
|
}
|
||||||
|
parent.removeChild(chip)
|
||||||
|
setCaretOffset(editor, offset)
|
||||||
|
syncFromDom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipBeforeCaret = () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
|
||||||
|
const { startContainer, startOffset } = selection.getRangeAt(0)
|
||||||
|
|
||||||
|
const insideOperand = findOperandWrapper(startContainer)
|
||||||
|
if (insideOperand) return insideOperand
|
||||||
|
|
||||||
|
if (startContainer === editor && startOffset > 0) {
|
||||||
|
const prev = editor.childNodes[startOffset - 1]
|
||||||
|
if (prev?.getAttribute?.('data-operand') != null) return prev
|
||||||
|
}
|
||||||
|
if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
|
||||||
|
const prev = startContainer.previousSibling
|
||||||
|
if (prev?.getAttribute?.('data-operand') != null) return prev
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipAfterCaret = () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
|
||||||
|
const { startContainer, startOffset } = selection.getRangeAt(0)
|
||||||
|
|
||||||
|
const insideOperand = findOperandWrapper(startContainer)
|
||||||
|
if (insideOperand) return insideOperand
|
||||||
|
|
||||||
|
if (startContainer === editor) {
|
||||||
|
const next = editor.childNodes[startOffset]
|
||||||
|
if (next?.getAttribute?.('data-operand') != null) return next
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
startContainer.nodeType === Node.TEXT_NODE &&
|
||||||
|
startOffset === (startContainer.textContent?.length ?? 0)
|
||||||
|
) {
|
||||||
|
const next = startContainer.nextSibling
|
||||||
|
if (next?.getAttribute?.('data-operand') != null) return next
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
onPressEnter?.(internalValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
const chip = chipBeforeCaret()
|
||||||
|
if (chip) {
|
||||||
|
event.preventDefault()
|
||||||
|
removeChip(chip)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Delete') {
|
||||||
|
const chip = chipAfterCaret()
|
||||||
|
if (chip) {
|
||||||
|
event.preventDefault()
|
||||||
|
removeChip(chip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event) => {
|
||||||
|
if (disabled) return
|
||||||
|
event.preventDefault()
|
||||||
|
const text = (event.clipboardData?.getData('text/plain') ?? '').replace(
|
||||||
|
/[\r\n]+/g,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const start = getOffsetAt(
|
||||||
|
editor,
|
||||||
|
range.startContainer,
|
||||||
|
range.startOffset,
|
||||||
|
'start'
|
||||||
|
)
|
||||||
|
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
|
||||||
|
const from = Math.min(start, end)
|
||||||
|
const to = Math.max(start, end)
|
||||||
|
const current = serializeNode(editor)
|
||||||
|
const next = `${current.slice(0, from)}${text}${current.slice(to)}`
|
||||||
|
const normalized = normalize(next)
|
||||||
|
paint(normalized, from + text.length)
|
||||||
|
emitChange(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (event) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || selection.isCollapsed) return
|
||||||
|
const text = getSelectedSerializedText(editor)
|
||||||
|
if (text == null) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.clipboardData?.setData('text/plain', text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCut = (event) => {
|
||||||
|
if (disabled) return
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || selection.isCollapsed) return
|
||||||
|
|
||||||
|
const text = getSelectedSerializedText(editor)
|
||||||
|
if (text == null) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.clipboardData?.setData('text/plain', text)
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const start = getOffsetAt(
|
||||||
|
editor,
|
||||||
|
range.startContainer,
|
||||||
|
range.startOffset,
|
||||||
|
'start'
|
||||||
|
)
|
||||||
|
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
|
||||||
|
const from = Math.min(start, end)
|
||||||
|
const to = Math.max(start, end)
|
||||||
|
const current = serializeNode(editor)
|
||||||
|
const next = `${current.slice(0, from)}${current.slice(to)}`
|
||||||
|
const normalized = normalize(next)
|
||||||
|
paint(normalized, from)
|
||||||
|
emitChange(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (disabled) return
|
||||||
|
paint('')
|
||||||
|
emitChange('')
|
||||||
|
editorRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll while click-dragging a selection past the visible edges.
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0
|
||||||
|
let lastClientX = null
|
||||||
|
|
||||||
|
const stopRaf = () => {
|
||||||
|
if (raf) {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
raf = 0
|
||||||
|
if (!selectingRef.current || !focusedRef.current || lastClientX == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const rect = editor.getBoundingClientRect()
|
||||||
|
const edge = 28
|
||||||
|
let dx = 0
|
||||||
|
if (lastClientX >= rect.right - edge) {
|
||||||
|
dx =
|
||||||
|
Math.min(28, Math.ceil((lastClientX - (rect.right - edge)) / 2) + 6)
|
||||||
|
} else if (lastClientX <= rect.left + edge) {
|
||||||
|
dx = -Math.min(
|
||||||
|
28,
|
||||||
|
Math.ceil((rect.left + edge - lastClientX) / 2) + 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dx !== 0) {
|
||||||
|
const maxScroll = editor.scrollWidth - editor.clientWidth
|
||||||
|
const next = Math.max(0, Math.min(maxScroll, editor.scrollLeft + dx))
|
||||||
|
if (next !== editor.scrollLeft) {
|
||||||
|
editor.scrollLeft = next
|
||||||
|
extendSelectionToPoint(
|
||||||
|
editor,
|
||||||
|
lastClientX,
|
||||||
|
rect.top + rect.height / 2
|
||||||
|
)
|
||||||
|
scrollSelectionIntoView(editor)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseMove = (event) => {
|
||||||
|
if (!selectingRef.current || !focusedRef.current) return
|
||||||
|
lastClientX = event.clientX
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
scrollPointIntoView(editor, event.clientX)
|
||||||
|
if (!raf) raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
selectingRef.current = false
|
||||||
|
lastClientX = null
|
||||||
|
stopRaf()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectionChange = () => {
|
||||||
|
if (!focusedRef.current) return
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !editor.contains(selection.focusNode)) return
|
||||||
|
scrollSelectionIntoView(editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('selectionchange', onSelectionChange)
|
||||||
|
return () => {
|
||||||
|
stopRaf()
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('selectionchange', onSelectionChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showPlaceholder = !internalValue && !focused
|
||||||
|
const showClear = allowClear && !!internalValue && !disabled
|
||||||
|
const affixCls = `${prefixCls}-affix-wrapper`
|
||||||
|
const placeholderPaddingInline =
|
||||||
|
mergedSize === 'large'
|
||||||
|
? (token.paddingInlineLG ?? token.paddingSM + 4)
|
||||||
|
: mergedSize === 'small'
|
||||||
|
? (token.paddingInlineSM ?? token.paddingXS)
|
||||||
|
: (token.paddingInline ?? token.paddingSM)
|
||||||
|
|
||||||
|
return wrapSharedCSSVar(
|
||||||
|
wrapCSSVar(
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
affixCls,
|
||||||
|
{
|
||||||
|
[`${affixCls}-focused`]: focused,
|
||||||
|
[`${affixCls}-disabled`]: disabled,
|
||||||
|
[`${affixCls}-sm`]: mergedSize === 'small',
|
||||||
|
[`${affixCls}-lg`]: mergedSize === 'large',
|
||||||
|
[`${affixCls}-rtl`]: direction === 'rtl',
|
||||||
|
[`${prefixCls}-outlined`]: true
|
||||||
|
},
|
||||||
|
compactItemClassnames,
|
||||||
|
cssVarCls,
|
||||||
|
rootCls,
|
||||||
|
hashId,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'text',
|
||||||
|
...style,
|
||||||
|
// Affix ::before strut needs inline-flex; display:block stacks it and inflates height.
|
||||||
|
display: 'inline-flex'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) editorRef.current?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.filter-input-editor::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{showPlaceholder && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
insetInlineStart: placeholderPaddingInline,
|
||||||
|
insetInlineEnd: showClear
|
||||||
|
? placeholderPaddingInline + 22
|
||||||
|
: placeholderPaddingInline,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: token.colorTextPlaceholder,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
className={classNames(prefixCls, 'filter-input-editor')}
|
||||||
|
role='textbox'
|
||||||
|
aria-multiline='false'
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck={false}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onCut={handleCut}
|
||||||
|
onMouseDown={() => {
|
||||||
|
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 && (
|
||||||
|
<span className={`${prefixCls}-suffix`}>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
`${prefixCls}-clear-icon`,
|
||||||
|
`${prefixCls}-clear-icon-has-value`
|
||||||
|
)}
|
||||||
|
role='button'
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseDown={handleClear}
|
||||||
|
>
|
||||||
|
<CloseCircleFilled />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterInput.propTypes = {
|
||||||
|
value: PropTypes.string,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
allowClear: PropTypes.bool,
|
||||||
|
className: PropTypes.string,
|
||||||
|
style: PropTypes.object,
|
||||||
|
onFocus: PropTypes.func,
|
||||||
|
onBlur: PropTypes.func,
|
||||||
|
onPressEnter: PropTypes.func,
|
||||||
|
size: PropTypes.oneOf(['small', 'middle', 'large'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterInput
|
||||||
|
export { OPERANDS, tokenize }
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Card, Select, Input, Button, Flex, Space, Dropdown } from 'antd'
|
import { Card, Select, Button, Flex, Space, Dropdown } from 'antd'
|
||||||
|
import FilterInput from './FilterInput'
|
||||||
import { CloseOutlined } from '@ant-design/icons'
|
import { CloseOutlined } from '@ant-design/icons'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import PlusIcon from '../../Icons/PlusIcon'
|
import PlusIcon from '../../Icons/PlusIcon'
|
||||||
@ -161,10 +162,10 @@ const FilterSidebar = ({
|
|||||||
style={{ minWidth: 80 }}
|
style={{ minWidth: 80 }}
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FilterInput
|
||||||
placeholder='Value'
|
placeholder='Value'
|
||||||
value={row.value}
|
value={row.value}
|
||||||
onChange={(e) => changeValue(row.field, e.target.value)}
|
onChange={(value) => changeValue(row.field, value)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
Spin,
|
Spin,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
|
||||||
Space,
|
Space,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Form,
|
Form,
|
||||||
@ -51,6 +50,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
|
|||||||
import FilterIcon from '../../Icons/FilterIcon'
|
import FilterIcon from '../../Icons/FilterIcon'
|
||||||
import ScrollBox from './ScrollBox'
|
import ScrollBox from './ScrollBox'
|
||||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||||
|
import FilterInput from './FilterInput'
|
||||||
import {
|
import {
|
||||||
getActiveFilterValues,
|
getActiveFilterValues,
|
||||||
useTableStatePersistence
|
useTableStatePersistence
|
||||||
@ -117,8 +117,7 @@ const ColumnFilterDropdown = ({
|
|||||||
setExpression(toFilterExpression(next) ?? '')
|
setExpression(toFilterExpression(next) ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExpressionChange = (e) => {
|
const handleExpressionChange = (text) => {
|
||||||
const text = e.target.value
|
|
||||||
setExpression(text)
|
setExpression(text)
|
||||||
setDraft(fromFilterExpression(text) || [])
|
setDraft(fromFilterExpression(text) || [])
|
||||||
}
|
}
|
||||||
@ -146,12 +145,12 @@ const ColumnFilterDropdown = ({
|
|||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
<Flex vertical gap='small'>
|
<Flex vertical gap='small'>
|
||||||
<Space.Compact>
|
<Space.Compact>
|
||||||
<Input
|
<FilterInput
|
||||||
placeholder={'Filter ' + propertyLabel}
|
placeholder={'Filter ' + propertyLabel}
|
||||||
value={expression}
|
value={expression}
|
||||||
onChange={handleExpressionChange}
|
onChange={handleExpressionChange}
|
||||||
onPressEnter={applyFilter}
|
onPressEnter={applyFilter}
|
||||||
style={{ width: 200, display: 'block' }}
|
style={{ width: 200 }}
|
||||||
/>
|
/>
|
||||||
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
||||||
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user