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:
parent
38e89e45c7
commit
9f4e6869a5
527
src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx
Normal file
527
src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user