Add SimpleDateTimePropertyFilter component for enhanced date and time filtering

- Introduced SimpleDateTimePropertyFilter component to handle filtering of date and time properties in the dashboard.
- Updated SimplePropertyFilter to integrate the new component, allowing for specialized handling of dateTime and date types.
- Enhanced filtering logic to improve user experience when dealing with date and time selections.
This commit is contained in:
Tom Butcher 2026-08-10 01:17:01 +01:00
parent 38e89e45c7
commit 9f4e6869a5
2 changed files with 545 additions and 2 deletions

View File

@ -0,0 +1,527 @@
import { useState, useEffect, useContext, useMemo, useCallback } from 'react'
import { Spin, Tree } from 'antd'
import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import { ApiServerContext } from '../context/ApiServerContext'
import { LoadingOutlined } from '@ant-design/icons'
const MONTH_NAMES = Array.from({ length: 12 }, (_, i) =>
dayjs().month(i).format('MMMM')
)
const pad2 = (n) => String(n).padStart(2, '0')
const nodeKey = {
year: (y) => `${y}`,
month: (y, m) => `${y}-${pad2(m)}`,
day: (y, m, d) => `${y}-${pad2(m)}-${pad2(d)}`,
hour: (y, m, d, h) => `${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}`,
minute: (y, m, d, h, min) =>
`${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}:${pad2(min)}`,
second: (y, m, d, h, min, s) =>
`${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}:${pad2(min)}:${pad2(s)}`
}
const lastDayOfMonth = (year, month) => new Date(year, month, 0).getDate()
const toYearExpression = (year) => `1 1 ${year}..31 12 ${year}`
const toMonthExpression = (year, month) => {
const last = lastDayOfMonth(year, month)
return `1 ${month} ${year}..${last} ${month} ${year}`
}
const toDayExpression = (year, month, day) => `${day} ${month} ${year}`
const toHourExpression = (year, month, day, hour) =>
`${day} ${month} ${year} ${hour}`
const toMinuteExpression = (year, month, day, hour, minute) =>
`${day} ${month} ${year} ${hour} ${minute}`
const toSecondExpression = (year, month, day, hour, minute, second) =>
`${day} ${month} ${year} ${hour} ${minute} ${second}`
const parseDateValue = (value) => {
if (value == null || value === '') return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
const hasNonMidnightTime = (date) =>
date.getHours() !== 0 ||
date.getMinutes() !== 0 ||
date.getSeconds() !== 0 ||
date.getMilliseconds() !== 0
/** Nested map: year → month → day → hour → minute → Set(second) */
const buildDateHierarchy = (dates) => {
const root = new Map()
for (const date of dates) {
const y = date.getFullYear()
const m = date.getMonth() + 1
const d = date.getDate()
const h = date.getHours()
const min = date.getMinutes()
const s = date.getSeconds()
if (!root.has(y)) root.set(y, new Map())
const months = root.get(y)
if (!months.has(m)) months.set(m, new Map())
const days = months.get(m)
if (!days.has(d)) days.set(d, { times: new Map(), dates: [] })
const dayNode = days.get(d)
dayNode.dates.push(date)
if (!dayNode.times.has(h)) dayNode.times.set(h, new Map())
const minutes = dayNode.times.get(h)
if (!minutes.has(min)) minutes.set(min, new Set())
minutes.get(min).add(s)
}
return root
}
const dayNeedsTimeLevels = (dayNode) => {
const { dates, times } = dayNode
if (dates.some(hasNonMidnightTime)) return true
let distinct = 0
for (const minutes of times.values()) {
for (const seconds of minutes.values()) {
distinct += seconds.size
if (distinct > 1) return true
}
}
return false
}
const sortNumericKeys = (map) => [...map.keys()].sort((a, b) => a - b)
const buildTreeData = (hierarchy) => {
const years = sortNumericKeys(hierarchy)
return years.map((year) => {
const monthsMap = hierarchy.get(year)
const monthNodes = sortNumericKeys(monthsMap).map((month) => {
const daysMap = monthsMap.get(month)
const dayNodes = sortNumericKeys(daysMap).map((day) => {
const dayNode = daysMap.get(day)
const includeTime = dayNeedsTimeLevels(dayNode)
let children
if (includeTime) {
children = sortNumericKeys(dayNode.times).map((hour) => {
const minutesMap = dayNode.times.get(hour)
const minuteNodes = sortNumericKeys(minutesMap).map((minute) => {
const seconds = [...minutesMap.get(minute)].sort((a, b) => a - b)
const secondNodes = seconds.map((second) => ({
key: nodeKey.second(year, month, day, hour, minute, second),
title: pad2(second),
level: 'second',
year,
month,
day,
hour,
minute,
second,
expression: toSecondExpression(
year,
month,
day,
hour,
minute,
second
),
isLeaf: true
}))
return {
key: nodeKey.minute(year, month, day, hour, minute),
title: pad2(minute),
level: 'minute',
year,
month,
day,
hour,
minute,
expression: toMinuteExpression(
year,
month,
day,
hour,
minute
),
children: secondNodes
}
})
return {
key: nodeKey.hour(year, month, day, hour),
title: `${pad2(hour)}:00`,
level: 'hour',
year,
month,
day,
hour,
expression: toHourExpression(year, month, day, hour),
children: minuteNodes
}
})
}
return {
key: nodeKey.day(year, month, day),
title: String(day),
level: 'day',
year,
month,
day,
expression: toDayExpression(year, month, day),
...(children ? { children } : { isLeaf: true })
}
})
return {
key: nodeKey.month(year, month),
title: MONTH_NAMES[month - 1],
level: 'month',
year,
month,
expression: toMonthExpression(year, month),
children: dayNodes
}
})
return {
key: nodeKey.year(year),
title: String(year),
level: 'year',
year,
expression: toYearExpression(year),
children: monthNodes
}
})
}
const collectAllKeys = (nodes, keys = []) => {
for (const node of nodes) {
keys.push(node.key)
if (node.children?.length) collectAllKeys(node.children, keys)
}
return keys
}
const collectLeafKeys = (nodes, keys = []) => {
for (const node of nodes) {
if (!node.children?.length) keys.push(node.key)
else collectLeafKeys(node.children, keys)
}
return keys
}
const indexNodesByKey = (nodes, map = new Map()) => {
for (const node of nodes) {
map.set(node.key, node)
if (node.children?.length) indexNodesByKey(node.children, map)
}
return map
}
/** Prefer fully-selected ancestors over listing every leaf (Excel-style). */
const collectCollapsedExpressions = (nodes, checkedSet) => {
const expressions = []
const walk = (nodeList) => {
for (const node of nodeList) {
const leafKeys = collectLeafKeys([node])
const allChecked =
leafKeys.length > 0 && leafKeys.every((key) => checkedSet.has(key))
if (allChecked) {
expressions.push(node.expression)
continue
}
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
return expressions
}
const filterTreeBySearch = (nodes, query) => {
if (!query) return nodes
const filterNodes = (list) => {
const result = []
for (const node of list) {
const titleMatch = String(node.title).toLowerCase().includes(query)
if (titleMatch) {
result.push(node)
continue
}
const filteredChildren = node.children?.length
? filterNodes(node.children)
: []
if (filteredChildren.length > 0) {
result.push({ ...node, children: filteredChildren })
}
}
return result
}
return filterNodes(nodes)
}
const parseExpressionToKeys = (expression, nodeByKey) => {
const text = String(expression).trim()
if (!text) return []
// Year: 1 1 Y..31 12 Y
let match = text.match(/^1 1 (\d+)\.\.31 12 \1$/)
if (match) {
const key = nodeKey.year(Number(match[1]))
return nodeByKey.has(key) ? [key] : []
}
// Month: 1 M Y..last M Y
match = text.match(/^1 (\d+) (\d+)\.\.(\d+) \1 \2$/)
if (match) {
const month = Number(match[1])
const year = Number(match[2])
const last = Number(match[3])
if (last === lastDayOfMonth(year, month)) {
const key = nodeKey.month(year, month)
return nodeByKey.has(key) ? [key] : []
}
}
// Day-first numeric: D M Y [H [Min [S]]]
match = text.match(
/^(\d+)\s+(\d+)\s+(\d+)(?:\s+(\d+)(?:\s+(\d+)(?:\s+(\d+))?)?)?$/
)
if (match) {
const day = Number(match[1])
const month = Number(match[2])
const year = Number(match[3])
const hour = match[4] != null ? Number(match[4]) : null
const minute = match[5] != null ? Number(match[5]) : null
const second = match[6] != null ? Number(match[6]) : null
let key
if (second != null) {
key = nodeKey.second(year, month, day, hour, minute, second)
} else if (minute != null) {
key = nodeKey.minute(year, month, day, hour, minute)
} else if (hour != null) {
key = nodeKey.hour(year, month, day, hour)
} else {
key = nodeKey.day(year, month, day)
}
return nodeByKey.has(key) ? [key] : []
}
// Interval spanning a single calendar day already handled; try ISO / Date parse
// for a specific instant check the deepest matching node.
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
const date = parseDateValue(text)
if (!date) return []
const y = date.getFullYear()
const m = date.getMonth() + 1
const d = date.getDate()
const h = date.getHours()
const min = date.getMinutes()
const s = date.getSeconds()
const candidates = [
nodeKey.second(y, m, d, h, min, s),
nodeKey.minute(y, m, d, h, min),
nodeKey.hour(y, m, d, h),
nodeKey.day(y, m, d)
]
for (const key of candidates) {
if (nodeByKey.has(key)) return [key]
}
}
return []
}
const expandKeysForChecked = (keys, nodeByKey) => {
const checked = new Set()
for (const key of keys) {
const node = nodeByKey.get(key)
if (!node) {
checked.add(key)
continue
}
for (const leaf of collectLeafKeys([node])) {
checked.add(leaf)
}
// Also mark intermediate keys so Tree shows parent check state correctly
checked.add(key)
if (node.children?.length) {
for (const childKey of collectAllKeys(node.children)) {
checked.add(childKey)
}
}
}
return [...checked]
}
const SimpleDateTimePropertyFilter = ({
modelType,
propertyName,
value = [],
onChange,
search = ''
}) => {
const { getModelPropertyValues } = useContext(ApiServerContext)
const [dates, setDates] = useState([])
const [loading, setLoading] = useState(false)
const [localChecked, setLocalChecked] = useState(null)
useEffect(() => {
let cancelled = false
const loadOptions = async () => {
if (!modelType || !propertyName) return
setLoading(true)
try {
const values = await getModelPropertyValues(modelType, propertyName)
if (cancelled) return
const parsed = (values || [])
.map(parseDateValue)
.filter(Boolean)
.sort((a, b) => a.getTime() - b.getTime())
setDates(parsed)
} finally {
if (!cancelled) setLoading(false)
}
}
loadOptions()
return () => {
cancelled = true
}
}, [modelType, propertyName, getModelPropertyValues])
const treeData = useMemo(
() => buildTreeData(buildDateHierarchy(dates)),
[dates]
)
const nodeByKey = useMemo(() => indexNodesByKey(treeData), [treeData])
const allKeys = useMemo(() => collectAllKeys(treeData), [treeData])
const allLeafKeys = useMemo(() => collectLeafKeys(treeData), [treeData])
const valueKey = useMemo(
() => JSON.stringify(value || []),
[value]
)
useEffect(() => {
if (treeData.length === 0) return
if (value?.length > 0) {
const matched = []
for (const expr of value) {
matched.push(...parseExpressionToKeys(expr, nodeByKey))
}
if (matched.length > 0) {
setLocalChecked(expandKeysForChecked(matched, nodeByKey))
} else {
// Unrecognized expression treat as no tree selection highlight
setLocalChecked([])
}
} else {
setLocalChecked(allKeys)
}
// valueKey captures value contents
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeData, valueKey, allKeys, nodeByKey])
const filteredTreeData = useMemo(() => {
const query = search.trim().toLowerCase()
return filterTreeBySearch(treeData, query)
}, [treeData, search])
const checkedKeys = localChecked ?? allKeys
const emitChange = useCallback(
(nextKeys) => {
setLocalChecked(nextKeys)
const checkedSet = new Set(nextKeys)
if (
allLeafKeys.length > 0 &&
allLeafKeys.every((key) => checkedSet.has(key))
) {
onChange?.([])
return
}
const expressions = collectCollapsedExpressions(treeData, checkedSet)
onChange?.(expressions)
},
[allLeafKeys, onChange, treeData]
)
const handleCheck = (checked) => {
const next = Array.isArray(checked) ? checked : checked.checked
const query = search.trim().toLowerCase()
if (!query) {
emitChange(next)
return
}
// Preserve checked leaves that are hidden by the search filter
const visibleLeafKeys = new Set(collectLeafKeys(filteredTreeData))
const hiddenSelectedLeaves = checkedKeys.filter((key) => {
const node = nodeByKey.get(key)
return node && !node.children?.length && !visibleLeafKeys.has(key)
})
const visibleSelectedLeaves = next.filter((key) => {
const node = nodeByKey.get(key)
return node && !node.children?.length
})
emitChange(
expandKeysForChecked(
[...hiddenSelectedLeaves, ...visibleSelectedLeaves],
nodeByKey
)
)
}
return (
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
{treeData.length > 0 ? (
<Tree
checkable
selectable={false}
treeData={filteredTreeData}
checkedKeys={checkedKeys}
onCheck={handleCheck}
defaultExpandAll={false}
style={{ minWidth: 0 }}
/>
) : null}
</Spin>
)
}
SimpleDateTimePropertyFilter.propTypes = {
modelType: PropTypes.string,
propertyName: PropTypes.string,
value: PropTypes.array,
onChange: PropTypes.func,
search: PropTypes.string
}
export default SimpleDateTimePropertyFilter

View File

@ -7,9 +7,13 @@ import {
getModelProperties
} from '../../../database/ObjectModels'
import ObjectProperty from './ObjectProperty'
import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter'
import { LoadingOutlined } from '@ant-design/icons'
const { Text } = Typography
const isDateTimeProperty = (property) =>
property?.type === 'dateTime' || property?.type === 'date'
const getOptionKey = (option) => {
if (option && typeof option === 'object') {
if (option.objectType) {
@ -63,7 +67,7 @@ const SimplePropertyFilter = ({
let cancelled = false
const loadOptions = async () => {
if (!modelType || !propertyName) return
if (!modelType || !propertyName || isDateTimeProperty(property)) return
setLoading(true)
try {
const values = await getModelPropertyValues(modelType, propertyName)
@ -81,7 +85,7 @@ const SimplePropertyFilter = ({
return () => {
cancelled = true
}
}, [modelType, propertyName, getModelPropertyValues])
}, [modelType, propertyName, getModelPropertyValues, property])
const valueKey = useMemo(
() => JSON.stringify((value || []).map(getOptionKey)),
@ -132,6 +136,18 @@ const SimplePropertyFilter = ({
emitChange([...hiddenSelected, ...visibleCheckedKeys])
}
if (isDateTimeProperty(property)) {
return (
<SimpleDateTimePropertyFilter
modelType={modelType}
propertyName={propertyName}
value={value}
onChange={onChange}
search={search}
/>
)
}
return (
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
<Checkbox.Group