diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx index 7c4186f..4eac600 100644 --- a/src/components/Dashboard/common/ObjectProperty.jsx +++ b/src/components/Dashboard/common/ObjectProperty.jsx @@ -6,11 +6,11 @@ import { InputNumber, Form, Select, - DatePicker, Switch } from 'antd' import IdDisplay from './IdDisplay' import TimeDisplay from './TimeDisplay' +import TimeEdit from './TimeEdit' import dayjs from 'dayjs' import EmailDisplay from './EmailDisplay' import UrlDisplay from './UrlDisplay' @@ -51,6 +51,10 @@ import { round } from '../utils/Utils' const { Text } = Typography +const timeEditFormItemProps = { + getValueProps: (v) => ({ value: v ? dayjs(v) : null }) +} + const ObjectProperty = ({ type = 'text', prefix, @@ -708,14 +712,7 @@ const ObjectProperty = ({ /> ) case 'dateTime': - return ( - - ) + return case 'country': return case 'color': @@ -854,9 +851,7 @@ const ObjectProperty = ({ {...(type === 'color' ? { valuePropName: 'value', getValueFromEvent: (v) => v } : {})} - {...(type === 'dateTime' - ? { getValueProps: (v) => ({ value: v ? dayjs(v) : null }) } - : {})} + {...(type === 'dateTime' ? timeEditFormItemProps : {})} > {renderInput()} diff --git a/src/components/Dashboard/common/TimeEdit.jsx b/src/components/Dashboard/common/TimeEdit.jsx new file mode 100644 index 0000000..1bc380a --- /dev/null +++ b/src/components/Dashboard/common/TimeEdit.jsx @@ -0,0 +1,180 @@ +import { useState, useRef } from 'react' +import PropTypes from 'prop-types' +import { DatePicker, Input } from 'antd' +import dayjs from 'dayjs' +import FunctionIcon from '../../Icons/FunctionIcon' + +const OPERATOR_KEYS = ['+', '-'] + +const TIME_UNITS = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year' +} + +const TERM_REGEX = /([+-])(\d+(?:\.\d+)?)([smhdwMy])/g +const RELATIVE_TERM_START = /[+-]\d+(?:\.\d+)?[smhdwMy]/ +const DATE_EXPR_FORMAT = 'YYYY-MM-DD HH:mm:ss' + +function formatExprBase(date) { + if (date == null || !dayjs(date).isValid()) return '' + return dayjs(date).format(DATE_EXPR_FORMAT) +} + +function applyTimeExpr(fallbackBase, expr) { + const trimmed = String(expr).trim() + if (!trimmed) return null + + const termStart = trimmed.search(RELATIVE_TERM_START) + if (termStart === -1) return null + + let base + let termsPart + + if (termStart === 0) { + base = + fallbackBase && dayjs(fallbackBase).isValid() + ? dayjs(fallbackBase) + : dayjs() + termsPart = trimmed + } else { + const baseStr = trimmed.slice(0, termStart).trim() + const parsed = dayjs(baseStr) + base = parsed.isValid() + ? parsed + : fallbackBase && dayjs(fallbackBase).isValid() + ? dayjs(fallbackBase) + : dayjs() + termsPart = trimmed.slice(termStart) + } + + const termsStr = termsPart.replace(/\s/g, '') + const terms = [...termsStr.matchAll(TERM_REGEX)] + if (!terms.length) return null + + const consumed = terms.map((t) => t[0]).join('') + if (consumed !== termsStr) return null + + let result = base + for (const [, sign, amount, unit] of terms) { + const unitKey = TIME_UNITS[unit] + if (!unitKey) return null + const delta = sign === '+' ? parseFloat(amount) : -parseFloat(amount) + result = result.add(delta, unitKey) + } + + return result +} + +const TimeEdit = ({ + value, + onChange, + onBlur, + disabled, + ...rest +}) => { + const [isExprMode, setIsExprMode] = useState(false) + const [exprValue, setExprValue] = useState('') + const inputRef = useRef(null) + + const dayjsValue = + value == null ? null : dayjs.isDayjs(value) ? value : dayjs(value) + const pickerValue = + dayjsValue && dayjsValue.isValid() ? dayjsValue : null + + const switchToExprMode = (initialValue) => { + setIsExprMode(true) + setExprValue(initialValue) + setTimeout(() => { + const input = inputRef.current?.getElementsByTagName('input')[0] + input?.focus() + }, 0) + } + + const exitExprMode = (result) => { + setIsExprMode(false) + setExprValue('') + if (result != null && result.isValid()) { + onChange?.(result) + } + } + + const commitExpr = () => { + const expr = exprValue.trim() + if (expr && expr.match(/[+-]/) && expr.match(/[smhdwMy]/)) { + const result = applyTimeExpr(pickerValue, expr) + if (result != null) { + exitExprMode(result) + return + } + } + exitExprMode(null) + } + + const handleDatePickerKeyDown = (e) => { + if (OPERATOR_KEYS.includes(e.key)) { + e.preventDefault() + e.stopPropagation() + switchToExprMode(formatExprBase(pickerValue) + e.key) + } + } + + const handleInputKeyDown = (e) => { + if (e.key === 'Enter' || e.key === '=') { + e.preventDefault() + const result = applyTimeExpr(pickerValue, exprValue) + if (result != null) { + exitExprMode(result) + } + } + } + + const handleInputBlur = (e) => { + commitExpr() + onBlur?.(e) + } + + if (isExprMode) { + return ( +
+ setExprValue(e.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={handleInputBlur} + disabled={disabled} + placeholder='+1d, -2m, +1h' + style={{ width: '100%' }} + /> +
+ +
+
+ ) + } + + return ( + + ) +} + +TimeEdit.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + onChange: PropTypes.func, + onBlur: PropTypes.func, + disabled: PropTypes.bool +} + +export default TimeEdit