diff --git a/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx b/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx index 25f3bb25..397971de 100644 --- a/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx +++ b/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx @@ -4,6 +4,10 @@ import PropTypes from 'prop-types' import dayjs from 'dayjs' import { ApiServerContext } from '../context/ApiServerContext' import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons' +import { + matchesFilterExpression, + valuesToExpression +} from './filterExpression' const { Text } = Typography @@ -271,6 +275,20 @@ const filterTreeBySearch = (nodes, query) => { 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 text = String(expression).trim() if (!text) return [] @@ -345,6 +363,31 @@ const parseExpressionToKeys = (expression, nodeByKey) => { 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 checked = new Set() @@ -484,14 +527,10 @@ const SimpleDateTimePropertyFilter = ({ if (treeData.length === 0) return if (value?.length > 0) { - const matched = [] - for (const expr of value) { - matched.push(...parseExpressionToKeys(expr, nodeByKey)) - } + const matched = matchTreeToFilterValues(value, nodeByKey, allLeafKeys) if (matched.length > 0) { setLocalChecked(expandKeysForChecked(matched, nodeByKey)) } else { - // Unrecognized expression — treat as no tree selection highlight setLocalChecked([]) } } else { @@ -499,7 +538,7 @@ const SimpleDateTimePropertyFilter = ({ } // valueKey captures value contents // eslint-disable-next-line react-hooks/exhaustive-deps - }, [treeData, valueKey, allKeys, nodeByKey]) + }, [treeData, valueKey, allKeys, allLeafKeys, nodeByKey]) const filteredTreeData = useMemo(() => { const query = search.trim().toLowerCase() diff --git a/src/components/Dashboard/common/SimplePropertyFilter.jsx b/src/components/Dashboard/common/SimplePropertyFilter.jsx index a9cc8104..d6e9e729 100644 --- a/src/components/Dashboard/common/SimplePropertyFilter.jsx +++ b/src/components/Dashboard/common/SimplePropertyFilter.jsx @@ -9,6 +9,10 @@ import { } from '../../../database/ObjectModels' import ObjectProperty from './ObjectProperty' import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter' +import { + getFieldOptions, + matchOptionsToFilterValues +} from './filterExpression' import { LoadingOutlined } from '@ant-design/icons' import MissingPlaceholder from './MissingPlaceholder' const { Text } = Typography @@ -46,10 +50,19 @@ const getDisplayValue = (option, property) => { return option } -const matchOptions = (options, selected) => { - if (!selected?.length) return [] - const selectedKeys = new Set(selected.map(getOptionKey)) - return options.filter((option) => selectedKeys.has(getOptionKey(option))) +const getCandidateValue = (option, property) => { + if (property?.type === 'number') { + const num = Number(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) => { @@ -217,17 +230,18 @@ const SimplePropertyFilter = ({ useEffect(() => { if (options.length === 0) return if (value?.length > 0) { - const matched = matchOptions(options, value) - const matchedKeys = matched.map(getOptionKey) - setLocalChecked( - matchedKeys.length > 0 ? matchedKeys : value.map(getOptionKey) - ) + const matchedKeys = matchOptionsToFilterValues(options, value, { + getOptionKey, + getCandidate: (option) => getCandidateValue(option, property), + fieldOptions: getFieldOptions(property) + }) + setLocalChecked(matchedKeys) } else { setLocalChecked(options.map(getOptionKey)) } // valueKey captures value contents; value is read for matching // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, valueKey]) + }, [options, valueKey, property]) const filteredOptions = useMemo(() => { const query = search.trim().toLowerCase() diff --git a/src/components/Dashboard/common/filterExpression.js b/src/components/Dashboard/common/filterExpression.js new file mode 100644 index 00000000..df83ab9d --- /dev/null +++ b/src/components/Dashboard/common/filterExpression.js @@ -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 +}