Add Filter Expression Utility and Integrate with DateTime Filters
This commit is contained in:
parent
291d5e438c
commit
d96b84513c
@ -4,6 +4,10 @@ import PropTypes from 'prop-types'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
matchesFilterExpression,
|
||||||
|
valuesToExpression
|
||||||
|
} from './filterExpression'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@ -271,6 +275,20 @@ const filterTreeBySearch = (nodes, query) => {
|
|||||||
return filterNodes(nodes)
|
return filterNodes(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getNodeDate = (node) => {
|
||||||
|
const { year, month, day, hour, minute, second } = node
|
||||||
|
if (second != null) {
|
||||||
|
return new Date(year, month - 1, day, hour, minute, second)
|
||||||
|
}
|
||||||
|
if (minute != null) {
|
||||||
|
return new Date(year, month - 1, day, hour, minute, 0)
|
||||||
|
}
|
||||||
|
if (hour != null) {
|
||||||
|
return new Date(year, month - 1, day, hour, 0, 0)
|
||||||
|
}
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const parseExpressionToKeys = (expression, nodeByKey) => {
|
const parseExpressionToKeys = (expression, nodeByKey) => {
|
||||||
const text = String(expression).trim()
|
const text = String(expression).trim()
|
||||||
if (!text) return []
|
if (!text) return []
|
||||||
@ -345,6 +363,31 @@ const parseExpressionToKeys = (expression, nodeByKey) => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchTreeToFilterValues = (value, nodeByKey, allLeafKeys) => {
|
||||||
|
if (!value?.length) return []
|
||||||
|
|
||||||
|
const matched = new Set()
|
||||||
|
const fieldOptions = { isDateField: true }
|
||||||
|
const expression = valuesToExpression(value)
|
||||||
|
|
||||||
|
for (const expr of value) {
|
||||||
|
for (const key of parseExpressionToKeys(expr, nodeByKey)) {
|
||||||
|
matched.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const leafKey of allLeafKeys) {
|
||||||
|
const node = nodeByKey.get(leafKey)
|
||||||
|
if (!node) continue
|
||||||
|
const date = getNodeDate(node)
|
||||||
|
if (matchesFilterExpression(expression, date, fieldOptions)) {
|
||||||
|
matched.add(leafKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...matched]
|
||||||
|
}
|
||||||
|
|
||||||
const expandKeysForChecked = (keys, nodeByKey) => {
|
const expandKeysForChecked = (keys, nodeByKey) => {
|
||||||
const checked = new Set()
|
const checked = new Set()
|
||||||
|
|
||||||
@ -484,14 +527,10 @@ const SimpleDateTimePropertyFilter = ({
|
|||||||
if (treeData.length === 0) return
|
if (treeData.length === 0) return
|
||||||
|
|
||||||
if (value?.length > 0) {
|
if (value?.length > 0) {
|
||||||
const matched = []
|
const matched = matchTreeToFilterValues(value, nodeByKey, allLeafKeys)
|
||||||
for (const expr of value) {
|
|
||||||
matched.push(...parseExpressionToKeys(expr, nodeByKey))
|
|
||||||
}
|
|
||||||
if (matched.length > 0) {
|
if (matched.length > 0) {
|
||||||
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
|
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
|
||||||
} else {
|
} else {
|
||||||
// Unrecognized expression — treat as no tree selection highlight
|
|
||||||
setLocalChecked([])
|
setLocalChecked([])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -499,7 +538,7 @@ const SimpleDateTimePropertyFilter = ({
|
|||||||
}
|
}
|
||||||
// valueKey captures value contents
|
// valueKey captures value contents
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [treeData, valueKey, allKeys, nodeByKey])
|
}, [treeData, valueKey, allKeys, allLeafKeys, nodeByKey])
|
||||||
|
|
||||||
const filteredTreeData = useMemo(() => {
|
const filteredTreeData = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import {
|
|||||||
} from '../../../database/ObjectModels'
|
} from '../../../database/ObjectModels'
|
||||||
import ObjectProperty from './ObjectProperty'
|
import ObjectProperty from './ObjectProperty'
|
||||||
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
|
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
|
||||||
|
import {
|
||||||
|
getFieldOptions,
|
||||||
|
matchOptionsToFilterValues
|
||||||
|
} from './filterExpression'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import MissingPlaceholder from './MissingPlaceholder'
|
import MissingPlaceholder from './MissingPlaceholder'
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@ -46,10 +50,19 @@ const getDisplayValue = (option, property) => {
|
|||||||
return option
|
return option
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchOptions = (options, selected) => {
|
const getCandidateValue = (option, property) => {
|
||||||
if (!selected?.length) return []
|
if (property?.type === 'number') {
|
||||||
const selectedKeys = new Set(selected.map(getOptionKey))
|
const num = Number(option)
|
||||||
return options.filter((option) => selectedKeys.has(getOptionKey(option)))
|
return Number.isNaN(num) ? getOptionKey(option) : num
|
||||||
|
}
|
||||||
|
if (property?.type === 'boolean') {
|
||||||
|
if (typeof option === 'boolean') return option
|
||||||
|
const lower = String(option).toLowerCase()
|
||||||
|
if (['true', 'yes', '1', 'on', 'y'].includes(lower)) return true
|
||||||
|
if (['false', 'no', '0', 'off', 'n'].includes(lower)) return false
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
return getOptionKey(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stableStringify = (value) => {
|
const stableStringify = (value) => {
|
||||||
@ -217,17 +230,18 @@ const SimplePropertyFilter = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.length === 0) return
|
if (options.length === 0) return
|
||||||
if (value?.length > 0) {
|
if (value?.length > 0) {
|
||||||
const matched = matchOptions(options, value)
|
const matchedKeys = matchOptionsToFilterValues(options, value, {
|
||||||
const matchedKeys = matched.map(getOptionKey)
|
getOptionKey,
|
||||||
setLocalChecked(
|
getCandidate: (option) => getCandidateValue(option, property),
|
||||||
matchedKeys.length > 0 ? matchedKeys : value.map(getOptionKey)
|
fieldOptions: getFieldOptions(property)
|
||||||
)
|
})
|
||||||
|
setLocalChecked(matchedKeys)
|
||||||
} else {
|
} else {
|
||||||
setLocalChecked(options.map(getOptionKey))
|
setLocalChecked(options.map(getOptionKey))
|
||||||
}
|
}
|
||||||
// valueKey captures value contents; value is read for matching
|
// valueKey captures value contents; value is read for matching
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [options, valueKey])
|
}, [options, valueKey, property])
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
|
|||||||
406
src/components/Dashboard/common/filterExpression.js
Normal file
406
src/components/Dashboard/common/filterExpression.js
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* Client-side filter expression parsing and matching.
|
||||||
|
* Mirrors the syntax supported by farmcontrol-api/src/utils.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OPERATORS = [
|
||||||
|
['<>', 'ne'],
|
||||||
|
['>=', 'gte'],
|
||||||
|
['<=', 'lte'],
|
||||||
|
['>', 'gt'],
|
||||||
|
['<', 'lt'],
|
||||||
|
['=', 'eq']
|
||||||
|
]
|
||||||
|
|
||||||
|
export const valuesToExpression = (values) => {
|
||||||
|
if (!values?.length) return ''
|
||||||
|
if (values.length === 1) return String(values[0])
|
||||||
|
return values.map((value) => {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return String(value._id ?? value.type ?? JSON.stringify(value))
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}).join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildWildcardRegexPattern = (input) => {
|
||||||
|
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.')
|
||||||
|
return `^${withWildcards}$`
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripIgnoreCase = (str) => (str.startsWith('@') ? str.slice(1) : str)
|
||||||
|
|
||||||
|
const isNumeric = (value) => String(value).trim() !== '' && !Number.isNaN(Number(value))
|
||||||
|
|
||||||
|
const parseBooleanOperand = (value) => {
|
||||||
|
const lower = String(value).trim().toLowerCase()
|
||||||
|
if (['yes', 'true', '1', 'on', 'y'].includes(lower)) return true
|
||||||
|
if (['no', 'false', '0', 'off', 'n'].includes(lower)) return false
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfDay = (date) => {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const endOfDay = (date) => {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setHours(23, 59, 59, 999)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeYear = (year) => {
|
||||||
|
if (year >= 100) return year
|
||||||
|
return year < 70 ? 2000 + year : 1900 + year
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDateOperand = (value, boundary = 'start') => {
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (!text) return null
|
||||||
|
const end = boundary === 'end'
|
||||||
|
|
||||||
|
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
|
||||||
|
const parsed = new Date(text)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null
|
||||||
|
if (!/[T:]/.test(text)) {
|
||||||
|
return end ? endOfDay(parsed) : startOfDay(parsed)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = text.split(/\s+/)
|
||||||
|
if (!parts.every((part) => /^\d+$/.test(part))) return null
|
||||||
|
const nums = parts.map(Number)
|
||||||
|
const now = new Date()
|
||||||
|
const day = nums[0]
|
||||||
|
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1
|
||||||
|
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear()
|
||||||
|
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0
|
||||||
|
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0
|
||||||
|
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0
|
||||||
|
const ms = end ? 999 : 0
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute, second, ms)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const coerceScalar = (value) => {
|
||||||
|
const lower = String(value).toLowerCase()
|
||||||
|
if (lower === 'true') return true
|
||||||
|
if (lower === 'false') return false
|
||||||
|
if (isNumeric(value)) return Number(value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const coerceBoundary = (value, isDateField, boundary) => {
|
||||||
|
if (isDateField) {
|
||||||
|
const date = parseDateOperand(value, boundary)
|
||||||
|
if (date) return date
|
||||||
|
}
|
||||||
|
return coerceScalar(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitTopLevel = (str, separator) => {
|
||||||
|
const parts = []
|
||||||
|
let depth = 0
|
||||||
|
let current = ''
|
||||||
|
for (const ch of str) {
|
||||||
|
if (ch === '(') depth++
|
||||||
|
else if (ch === ')') depth = Math.max(0, depth - 1)
|
||||||
|
|
||||||
|
if (ch === separator && depth === 0) {
|
||||||
|
parts.push(current)
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(current)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWrappedInParens = (str) => {
|
||||||
|
if (!str.startsWith('(') || !str.endsWith(')')) return false
|
||||||
|
let depth = 0
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str[i] === '(') depth++
|
||||||
|
else if (str[i] === ')') {
|
||||||
|
depth--
|
||||||
|
if (depth === 0 && i < str.length - 1) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseExpression = (str) => {
|
||||||
|
const orParts = splitTopLevel(str, '|')
|
||||||
|
if (orParts.length > 1) {
|
||||||
|
return { type: 'or', items: orParts.map(parseExpression) }
|
||||||
|
}
|
||||||
|
const andParts = splitTopLevel(str, '&')
|
||||||
|
if (andParts.length > 1) {
|
||||||
|
return { type: 'and', items: andParts.map(parseExpression) }
|
||||||
|
}
|
||||||
|
const trimmed = str.trim()
|
||||||
|
if (isWrappedInParens(trimmed)) {
|
||||||
|
return parseExpression(trimmed.slice(1, -1))
|
||||||
|
}
|
||||||
|
return { type: 'leaf', token: trimmed }
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcardMatch = (candidate, pattern) => {
|
||||||
|
const regex = new RegExp(buildWildcardRegexPattern(pattern), 'i')
|
||||||
|
return regex.test(String(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareValues = (left, right) => {
|
||||||
|
if (left instanceof Date && right instanceof Date) {
|
||||||
|
return left.getTime() - right.getTime()
|
||||||
|
}
|
||||||
|
if (typeof left === 'number' && typeof right === 'number') {
|
||||||
|
return left - right
|
||||||
|
}
|
||||||
|
return String(left).localeCompare(String(right), undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchEquality = (candidate, value, { isDateField, isBooleanField, isNumberField }) => {
|
||||||
|
if (isBooleanField) {
|
||||||
|
const bool = parseBooleanOperand(value)
|
||||||
|
return bool !== undefined && candidate === bool
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDateField) {
|
||||||
|
if (!(candidate instanceof Date) || Number.isNaN(candidate.getTime())) return false
|
||||||
|
const start = parseDateOperand(value, 'start')
|
||||||
|
const end = parseDateOperand(value, 'end')
|
||||||
|
if (!start || !end) return false
|
||||||
|
const time = candidate.getTime()
|
||||||
|
return time >= start.getTime() && time <= end.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumberField) {
|
||||||
|
if (typeof candidate !== 'number' || Number.isNaN(candidate)) {
|
||||||
|
if (!isNumeric(candidate)) return false
|
||||||
|
candidate = Number(candidate)
|
||||||
|
}
|
||||||
|
if (/[*?]/.test(value)) {
|
||||||
|
return wildcardMatch(candidate, value)
|
||||||
|
}
|
||||||
|
return isNumeric(value) && candidate === Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[*?]/.test(value)) {
|
||||||
|
return wildcardMatch(candidate, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof candidate === 'number' && isNumeric(value)) {
|
||||||
|
return candidate === Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(candidate).toLowerCase() === String(value).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchComparison = (
|
||||||
|
name,
|
||||||
|
candidate,
|
||||||
|
value,
|
||||||
|
{ isDateField, isBooleanField, isNumberField }
|
||||||
|
) => {
|
||||||
|
if (isBooleanField) {
|
||||||
|
if (name === 'eq') return matchEquality(candidate, value, { isBooleanField })
|
||||||
|
if (name === 'ne') {
|
||||||
|
const bool = parseBooleanOperand(value)
|
||||||
|
return bool !== undefined && candidate !== bool
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'eq') {
|
||||||
|
return matchEquality(candidate, value, {
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isNumberField
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'ne') {
|
||||||
|
if (isDateField) {
|
||||||
|
if (!(candidate instanceof Date)) return false
|
||||||
|
const start = parseDateOperand(value, 'start')
|
||||||
|
const end = parseDateOperand(value, 'end')
|
||||||
|
if (!start || !end) return false
|
||||||
|
const time = candidate.getTime()
|
||||||
|
return time < start.getTime() || time > end.getTime()
|
||||||
|
}
|
||||||
|
if (/[*?]/.test(value)) {
|
||||||
|
return !wildcardMatch(candidate, value)
|
||||||
|
}
|
||||||
|
if (isNumberField) {
|
||||||
|
if (typeof candidate !== 'number') candidate = Number(candidate)
|
||||||
|
if (!isNumeric(value)) return false
|
||||||
|
return candidate !== Number(value)
|
||||||
|
}
|
||||||
|
return String(candidate).toLowerCase() !== String(value).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start'
|
||||||
|
const bound = coerceBoundary(value, isDateField, boundary)
|
||||||
|
|
||||||
|
if (isDateField) {
|
||||||
|
if (!(candidate instanceof Date) || !(bound instanceof Date)) return false
|
||||||
|
const cmp = compareValues(candidate, bound)
|
||||||
|
if (name === 'gt') return cmp > 0
|
||||||
|
if (name === 'gte') return cmp >= 0
|
||||||
|
if (name === 'lt') return cmp < 0
|
||||||
|
if (name === 'lte') return cmp <= 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumberField) {
|
||||||
|
if (!isNumeric(value)) return false
|
||||||
|
const numCandidate =
|
||||||
|
typeof candidate === 'number' ? candidate : Number(candidate)
|
||||||
|
const numBound = Number(value)
|
||||||
|
if (Number.isNaN(numCandidate)) return false
|
||||||
|
if (name === 'gt') return numCandidate > numBound
|
||||||
|
if (name === 'gte') return numCandidate >= numBound
|
||||||
|
if (name === 'lt') return numCandidate < numBound
|
||||||
|
if (name === 'lte') return numCandidate <= numBound
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmp = compareValues(candidate, bound)
|
||||||
|
if (name === 'gt') return cmp > 0
|
||||||
|
if (name === 'gte') return cmp >= 0
|
||||||
|
if (name === 'lt') return cmp < 0
|
||||||
|
if (name === 'lte') return cmp <= 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchRange = (candidate, lo, hi, { isDateField, isNumberField }) => {
|
||||||
|
if (isDateField) {
|
||||||
|
if (!(candidate instanceof Date)) return false
|
||||||
|
const time = candidate.getTime()
|
||||||
|
if (lo !== '') {
|
||||||
|
const start = parseDateOperand(lo, 'start')
|
||||||
|
if (!start || time < start.getTime()) return false
|
||||||
|
}
|
||||||
|
if (hi !== '') {
|
||||||
|
const end = parseDateOperand(hi, 'end')
|
||||||
|
if (!end || time > end.getTime()) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumberField) {
|
||||||
|
const numCandidate =
|
||||||
|
typeof candidate === 'number' ? candidate : Number(candidate)
|
||||||
|
if (Number.isNaN(numCandidate)) return false
|
||||||
|
if (lo !== '') {
|
||||||
|
if (!isNumeric(lo)) return false
|
||||||
|
if (numCandidate < Number(lo)) return false
|
||||||
|
}
|
||||||
|
if (hi !== '') {
|
||||||
|
if (!isNumeric(hi)) return false
|
||||||
|
if (numCandidate > Number(hi)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(candidate)
|
||||||
|
if (lo !== '' && compareValues(str, lo) < 0) return false
|
||||||
|
if (hi !== '' && compareValues(str, hi) > 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchLeaf = (candidate, rawToken, fieldOptions) => {
|
||||||
|
const token = rawToken.trim()
|
||||||
|
if (token === '') {
|
||||||
|
return fieldOptions.isBooleanField || fieldOptions.isNumberField
|
||||||
|
? false
|
||||||
|
: String(candidate) === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeIdx = token.indexOf('..')
|
||||||
|
if (rangeIdx !== -1) {
|
||||||
|
if (fieldOptions.isBooleanField) return false
|
||||||
|
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim())
|
||||||
|
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim())
|
||||||
|
if (lo === '' && hi === '') return true
|
||||||
|
return matchRange(candidate, lo, hi, fieldOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [symbol, name] of OPERATORS) {
|
||||||
|
if (token.startsWith(symbol)) {
|
||||||
|
return matchComparison(
|
||||||
|
name,
|
||||||
|
candidate,
|
||||||
|
stripIgnoreCase(token.slice(symbol.length).trim()),
|
||||||
|
fieldOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchEquality(candidate, stripIgnoreCase(token), fieldOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalNode = (candidate, node, fieldOptions) => {
|
||||||
|
if (node.type === 'or') {
|
||||||
|
return node.items.some((item) => evalNode(candidate, item, fieldOptions))
|
||||||
|
}
|
||||||
|
if (node.type === 'and') {
|
||||||
|
return node.items.every((item) => evalNode(candidate, item, fieldOptions))
|
||||||
|
}
|
||||||
|
return matchLeaf(candidate, node.token, fieldOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDateTimePropertyType = (property) =>
|
||||||
|
property?.type === 'dateTime' || property?.type === 'date'
|
||||||
|
|
||||||
|
export const getFieldOptions = (property) => ({
|
||||||
|
isDateField: isDateTimePropertyType(property),
|
||||||
|
isBooleanField: property?.type === 'boolean',
|
||||||
|
isNumberField: property?.type === 'number'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `candidate` satisfies the filter `expression`.
|
||||||
|
*/
|
||||||
|
export const matchesFilterExpression = (expression, candidate, fieldOptions = {}) => {
|
||||||
|
const text = String(expression ?? '').trim()
|
||||||
|
if (!text) return true
|
||||||
|
try {
|
||||||
|
const ast = parseExpression(text)
|
||||||
|
return evalNode(candidate, ast, fieldOptions)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns option keys from `options` that match any of the filter `values`.
|
||||||
|
*/
|
||||||
|
export const matchOptionsToFilterValues = (
|
||||||
|
options,
|
||||||
|
values,
|
||||||
|
{ getOptionKey, getCandidate, fieldOptions } = {}
|
||||||
|
) => {
|
||||||
|
if (!values?.length || !options?.length) return []
|
||||||
|
const expression = valuesToExpression(values)
|
||||||
|
if (!expression.trim()) return options.map(getOptionKey)
|
||||||
|
|
||||||
|
const matched = []
|
||||||
|
for (const option of options) {
|
||||||
|
const candidate = getCandidate(option)
|
||||||
|
if (matchesFilterExpression(expression, candidate, fieldOptions)) {
|
||||||
|
matched.push(getOptionKey(option))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user