diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 0e9b63c9..4c1ba249 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -2800,6 +2800,11 @@ body.objectKanbanColumnResizing * { color: var(--color-primary); } +.filter-input-date-unit, +[data-date-unit] { + opacity: 0.5; +} + .filter-input-operand-tag { margin-inline-end: 0; margin-right: 0; diff --git a/src/components/Dashboard/Management/AuditLogs.jsx b/src/components/Dashboard/Management/AuditLogs.jsx index 8d37e798..15b9ae9a 100644 --- a/src/components/Dashboard/Management/AuditLogs.jsx +++ b/src/components/Dashboard/Management/AuditLogs.jsx @@ -12,6 +12,7 @@ import ColumnViewButton from '../common/ColumnViewButton' import ExportListButton from '../common/ExportListButton' import ListViewTabs from '../common/ListViewTabs' import ListViewEditButtons from '../common/ListViewEditButtons' +import ObjectTableViewButton from '../common/ObjectTableViewButton' import { ObjectListViewProvider } from '../context/ObjectListViewContext' const AuditLogs = () => { @@ -26,15 +27,11 @@ const AuditLogs = () => { const [showSortSidebar, setShowSortSidebar] = useSortSidebarVisibility('AuditLogs') - return ( - - - - + + + + { /> - - + + setShowSortSidebar(!showSortSidebar)} @@ -58,9 +55,10 @@ const AuditLogs = () => { active={showFilterSidebar} onClick={() => setShowFilterSidebar(!showFilterSidebar)} /> + - + { saveSortInUrl={true} useSortInSession={true} useSortInUrl={true} - /> - - + + ) } diff --git a/src/components/Dashboard/common/FilterInput.jsx b/src/components/Dashboard/common/FilterInput.jsx index cad09d00..68e150b4 100644 --- a/src/components/Dashboard/common/FilterInput.jsx +++ b/src/components/Dashboard/common/FilterInput.jsx @@ -84,28 +84,58 @@ const OPERAND_BY_SYMBOL = Object.fromEntries( OPERANDS.map((operand) => [operand.symbol, operand]) ) +const DATE_HIGHLIGHT_RE = /TODAY|[+-]?(?:\d+|C)[DdWwYyHhMm]/gi + +const expandDateHighlightTokens = (text) => { + const tokens = [] + const pattern = new RegExp(DATE_HIGHLIGHT_RE.source, DATE_HIGHLIGHT_RE.flags) + let lastIndex = 0 + let match = pattern.exec(text) + + while (match) { + if (match.index > lastIndex) { + tokens.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + const raw = match[0] + if (/^TODAY$/i.test(raw)) { + tokens.push({ type: 'dateUnit', value: raw }) + } else { + tokens.push({ type: 'text', value: raw.slice(0, -1) }) + tokens.push({ type: 'dateUnit', value: raw.slice(-1) }) + } + lastIndex = match.index + raw.length + match = pattern.exec(text) + } + + if (lastIndex < text.length) { + tokens.push({ type: 'text', value: text.slice(lastIndex) }) + } + return tokens +} + const tokenize = (raw = '') => { const value = String(raw) const tokens = [] let text = '' let i = 0 + const pushText = (value) => { + if (!value) return + tokens.push(...expandDateHighlightTokens(value)) + } + while (i < value.length) { const match = OPERANDS.find((operand) => value.startsWith(operand.symbol, i) ) if (match) { - if (text) { - tokens.push({ type: 'text', value: text }) - text = '' - } + pushText(text) + text = '' tokens.push({ type: 'operand', value: match.symbol }) i += match.symbol.length } else if (WILDCARDS.has(value[i])) { - if (text) { - tokens.push({ type: 'text', value: text }) - text = '' - } + pushText(text) + text = '' tokens.push({ type: 'wildcard', value: value[i] }) i += 1 } else { @@ -114,7 +144,7 @@ const tokenize = (raw = '') => { } } - if (text) tokens.push({ type: 'text', value: text }) + pushText(text) return tokens } @@ -126,6 +156,14 @@ const renderWildcardNode = (char) => { return span } +const renderDateUnitNode = (value) => { + const span = document.createElement('span') + span.setAttribute('data-date-unit', value) + span.className = 'filter-input-date-unit' + span.textContent = value + return span +} + const normalize = (raw = '') => tokenize(raw) .map((token) => token.value) @@ -375,7 +413,8 @@ const domMatchesTokens = (root, raw) => { if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== '' return ( node.getAttribute?.('data-operand') != null || - node.getAttribute?.('data-wildcard') != null + node.getAttribute?.('data-wildcard') != null || + node.getAttribute?.('data-date-unit') != null ) }) @@ -404,6 +443,12 @@ const domMatchesTokens = (root, raw) => { (child.textContent ?? '') === token.value ) } + if (token.type === 'dateUnit') { + return ( + child.getAttribute?.('data-date-unit') === token.value && + (child.textContent ?? '') === token.value + ) + } return child.getAttribute?.('data-operand') === token.value }) } @@ -598,6 +643,8 @@ const FilterInput = ({ nextChildren.push(document.createTextNode(item.value)) } else if (item.type === 'wildcard') { nextChildren.push(renderWildcardNode(item.value)) + } else if (item.type === 'dateUnit') { + nextChildren.push(renderDateUnitNode(item.value)) } else { nextChildren.push(takeChip(item.value)) } diff --git a/src/components/Dashboard/common/ObjectTimeline.jsx b/src/components/Dashboard/common/ObjectTimeline.jsx index 879d30d6..2295e700 100644 --- a/src/components/Dashboard/common/ObjectTimeline.jsx +++ b/src/components/Dashboard/common/ObjectTimeline.jsx @@ -13,11 +13,7 @@ import ScrollBox from './ScrollBox' import MissingPlaceholder from './MissingPlaceholder' import LoadingPlaceholder from './LoadingPlaceholder' import ObjectTimelineRow from './ObjectTimelineRow' -import { - getTimelineRange, - getTimelineRangeFromValues, - getTimelineTicks -} from './timelineUtils' +import { getTimelineRangeFromValues, getTimelineTicks } from './timelineUtils' import cn from 'classnames' import { getStateTagInfo } from '../utils/Utils' @@ -75,8 +71,6 @@ const ObjectTimeline = ({ JSON.parse(rangeQueryKey) const loadRangeValues = async () => { - setStartValues([]) - setEndValues([]) try { const startPromise = getModelPropertyValuesRef.current( type, @@ -124,14 +118,20 @@ const ObjectTimeline = ({ [onScroll] ) - const timelineRange = useMemo(() => { - const fromValues = getTimelineRangeFromValues(startValues, endValues) - if (fromValues) return fromValues - return getTimelineRange(records, startDate, endDate) - }, [endDate, endValues, records, startDate, startValues]) + const timelineRange = useMemo( + () => getTimelineRangeFromValues(startValues, endValues), + [endValues, startValues] + ) + const lastTimelineRangeRef = useRef(null) + if (timelineRange) { + lastTimelineRangeRef.current = timelineRange + } const range = useMemo( - () => timelineRange || getTimelineRangeFromValues([new Date()]), + () => + timelineRange || + lastTimelineRangeRef.current || + getTimelineRangeFromValues([new Date()]), [timelineRange] ) diff --git a/src/components/Dashboard/common/QuickPropertyFilters.jsx b/src/components/Dashboard/common/QuickPropertyFilters.jsx index 410628b4..8e85b797 100644 --- a/src/components/Dashboard/common/QuickPropertyFilters.jsx +++ b/src/components/Dashboard/common/QuickPropertyFilters.jsx @@ -182,12 +182,118 @@ const getFilterOptions = (category) => { ] case 'date': return [ - { key: 'equals', label: 'On', needsValue: true }, + { key: 'equals', label: 'Equals', needsValue: true }, { type: 'divider' }, { key: 'lessThan', label: 'Before', needsValue: true }, { key: 'greaterThan', label: 'After', needsValue: true }, + { key: 'between', label: 'Between', needsRange: true }, { type: 'divider' }, - { key: 'between', label: 'Between', needsRange: true } + { key: 'yesterday', label: 'Yesterday', expression: '-1D' }, + { key: 'today', label: 'Today', expression: 'TODAY' }, + { key: 'tomorrow', label: 'Tomorrow', expression: '+1D' }, + { type: 'divider' }, + { + key: 'this', + label: 'This', + children: [ + { key: 'thisMinute', label: 'This minute', expression: 'Cm' }, + { key: 'thisHour', label: 'This hour', expression: 'CH' }, + { key: 'thisDay', label: 'This day', expression: 'CD' }, + { key: 'thisWeek', label: 'This week', expression: 'CW' }, + { key: 'thisMonth', label: 'This month', expression: 'CM' }, + { key: 'thisYear', label: 'This year', expression: 'CY' } + ] + }, + + { + key: 'last', + label: 'Last', + children: [ + { key: 'last5Minutes', label: '5 mins', expression: '-5m..TODAY' }, + { + key: 'last15Minutes', + label: '15 mins', + expression: '-15m..TODAY' + }, + { key: 'lastMinute', label: '30 mins', expression: '-30m..TODAY' }, + { key: 'lastHour', label: 'Hour', expression: '-1H..TODAY' }, + { + key: 'last12Hours', + label: '12 hours', + expression: '-12H..TODAY' + }, + { type: 'divider' }, + { key: 'lastDay', label: 'Day', expression: '-1D..TODAY' }, + { key: 'last3Days', label: '3 days', expression: '-3D..TODAY' }, + { key: 'lastWeek', label: 'Week', expression: '-1W' }, + { key: 'last7Days', label: '7 days', expression: '-7D..TODAY' }, + { type: 'divider' }, + { key: 'lastMonth', label: 'Month', expression: '-1M' }, + { key: 'last30Days', label: '30 days', expression: '-30D..TODAY' }, + { key: 'last3Months', label: '3 months', expression: '-3M' }, + { key: 'last90Days', label: '90 days', expression: '-90D' }, + { key: 'last6Months', label: '6 months', expression: '-6M' }, + { key: 'last180Days', label: '180 days', expression: '-180D' }, + { type: 'divider' }, + { key: 'lastYear', label: 'Year', expression: '-1Y' }, + { key: 'last2Years', label: '2 years', expression: '-2Y' } + ] + }, + { + key: 'next', + label: 'Next', + children: [ + { key: 'next5Minutes', label: '5 mins', expression: '+5m..TODAY' }, + { + key: 'next15Minutes', + label: '15 mins', + expression: '+15m..TODAY' + }, + { key: 'nextMinute', label: '30 mins', expression: '+30m..TODAY' }, + { key: 'nextHour', label: 'Hour', expression: '+1H..TODAY' }, + { + key: 'next12Hours', + label: '12 hours', + expression: '+12H..TODAY' + }, + { type: 'divider' }, + { key: 'nextDay', label: 'Day', expression: 'Today..+1D' }, + { key: 'next3Days', label: '3 days', expression: 'Today..+3D' }, + { key: 'nextWeek', label: 'Week', expression: '+1W' }, + { key: 'next7Days', label: '7 days', expression: 'Today..+7D' }, + { type: 'divider' }, + { key: 'nextMonth', label: 'Month', expression: '+1M' }, + { key: 'next30Days', label: '30 days', expression: 'Today..+30D' }, + { key: 'next3Months', label: '3 months', expression: '+3M' }, + { key: 'next90Days', label: '90 days', expression: 'Today..+90D' }, + { key: 'next6Months', label: '6 months', expression: '+6M' }, + { + key: 'next180Days', + label: '180 days', + expression: 'Today..+180D' + }, + { type: 'divider' }, + { key: 'nextYear', label: 'Year', expression: '+1Y' }, + { key: 'next2Years', label: '2 years', expression: '+2Y' } + ] + }, + { type: 'divider' }, + { + key: 'fromToday', + label: 'From today', + expression: 'TODAY..' + }, + { + key: 'untilToday', + label: 'Until today', + expression: '..TODAY' + }, + { type: 'divider' }, + { + key: 'yearToDate', + label: 'Year to Date', + expression: 'CY..TODAY' + } ] case 'object': return [ @@ -225,7 +331,14 @@ const buildFilterMenuItems = (options, onOptionClick) => : { key: item.key, label: item.label, - onClick: () => onOptionClick(item) + onClick: () => + !item.children || item.children.length === 0 + ? onOptionClick(item) + : undefined, + children: + item.children && item.children.length > 0 + ? buildFilterMenuItems(item.children, onOptionClick) + : undefined } ) @@ -275,6 +388,10 @@ const QuickPropertyFilters = ({ } const handleOptionClick = (option) => { + if (option.expression != null) { + onChange?.([option.expression]) + return + } if (option.key === 'isTrue' || option.key === 'isFalse') { applyFilter(option.key) return diff --git a/src/components/Dashboard/common/filterExpression.js b/src/components/Dashboard/common/filterExpression.js index df83ab9d..e3d384de 100644 --- a/src/components/Dashboard/common/filterExpression.js +++ b/src/components/Dashboard/common/filterExpression.js @@ -53,16 +53,213 @@ const endOfDay = (date) => { return d } +const startOfMonth = (date) => + new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0) + +const endOfMonth = (date) => + new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999) + +const shiftCalendarMonths = (date, n) => + new Date(date.getFullYear(), date.getMonth() + n, 1) + +const startOfWeek = (date) => { + const d = startOfDay(date) + const day = d.getDay() + const mondayOffset = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + mondayOffset) + return d +} + +const endOfWeek = (date) => { + const start = startOfWeek(date) + const end = new Date(start) + end.setDate(end.getDate() + 6) + return endOfDay(end) +} + +const shiftWeeks = (date, n) => { + const d = new Date(date) + d.setDate(d.getDate() + n * 7) + return d +} + +const startOfYear = (date) => new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0) + +const endOfYear = (date) => + new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999) + +const shiftYears = (date, n) => new Date(date.getFullYear() + n, 0, 1) + +const startOfHour = (date) => { + const d = new Date(date) + d.setMinutes(0, 0, 0) + return d +} + +const endOfHour = (date) => { + const d = new Date(date) + d.setMinutes(59, 59, 999) + return d +} + +const startOfMinute = (date) => { + const d = new Date(date) + d.setSeconds(0, 0) + return d +} + +const endOfMinute = (date) => { + const d = new Date(date) + d.setSeconds(59, 999) + return d +} + const normalizeYear = (year) => { if (year >= 100) return year return year < 70 ? 2000 + year : 1900 + year } +const RELATIVE_OFFSET_RE = /^([+-])?(\d+)([DdWwYyHhMm])$/ +const RELATIVE_CURRENT_RE = /^([+-])?C([DdWwYyHhMm])$/ + +const normalizeRelativeUnit = (letter) => { + if (letter === 'M' || letter === 'm') return letter + return letter.toUpperCase() +} + +const resolveRelativeUnit = ( + now, + unit, + amount, + boundary, + isCurrent, + minusCurrent +) => { + const end = boundary === 'end' + + if (unit === 'D') { + if (isCurrent) return end ? endOfDay(now) : startOfDay(now) + const date = new Date(now) + date.setDate(date.getDate() + amount) + return end ? endOfDay(date) : startOfDay(date) + } + + if (unit === 'W') { + if (isCurrent && minusCurrent) { + return end ? endOfWeek(shiftWeeks(now, -1)) : startOfWeek(now) + } + const target = isCurrent ? now : shiftWeeks(now, amount) + return end ? endOfWeek(target) : startOfWeek(target) + } + + if (unit === 'M') { + if (isCurrent) { + if (minusCurrent) { + return end + ? endOfMonth(shiftCalendarMonths(now, -1)) + : startOfMonth(now) + } + return end ? endOfMonth(now) : startOfMonth(now) + } + const month = shiftCalendarMonths(now, amount) + return end ? endOfMonth(month) : startOfMonth(month) + } + + if (unit === 'Y') { + if (isCurrent) { + if (minusCurrent) { + return end ? endOfYear(shiftYears(now, -1)) : startOfYear(now) + } + return end ? endOfYear(now) : startOfYear(now) + } + const year = shiftYears(now, amount) + return end ? endOfYear(year) : startOfYear(year) + } + + if (unit === 'H') { + if (isCurrent) { + if (minusCurrent) { + const prev = new Date(now) + prev.setHours(prev.getHours() - 1) + return end ? endOfHour(prev) : startOfHour(now) + } + return end ? endOfHour(now) : startOfHour(now) + } + const date = new Date(now) + date.setHours(date.getHours() + amount) + return end ? endOfHour(date) : startOfHour(date) + } + + if (unit === 'm') { + if (isCurrent) { + if (minusCurrent) { + const prev = new Date(now) + prev.setMinutes(prev.getMinutes() - 1) + return end ? endOfMinute(prev) : startOfMinute(now) + } + return end ? endOfMinute(now) : startOfMinute(now) + } + const date = new Date(now) + date.setMinutes(date.getMinutes() + amount) + return end ? endOfMinute(date) : startOfMinute(date) + } + + return null +} + +const parseRelativeDateOperand = (text, boundary = 'start') => { + const raw = String(text).trim() + if (!raw) return null + + if (/^TODAY$/i.test(raw)) { + const now = new Date() + return boundary === 'end' ? endOfDay(now) : startOfDay(now) + } + + const offset = raw.match(RELATIVE_OFFSET_RE) + if (offset) { + const amount = (offset[1] === '-' ? -1 : 1) * Number(offset[2]) + return resolveRelativeUnit( + new Date(), + normalizeRelativeUnit(offset[3]), + amount, + boundary, + false, + false + ) + } + + const current = raw.match(RELATIVE_CURRENT_RE) + if (current) { + return resolveRelativeUnit( + new Date(), + normalizeRelativeUnit(current[2]), + 0, + boundary, + true, + current[1] === '-' + ) + } + + return null +} + +const dateComponentsMatch = (date, year, month, day, hour, minute, second) => + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day && + date.getHours() === hour && + date.getMinutes() === minute && + date.getSeconds() === second + const parseDateOperand = (value, boundary = 'start') => { const text = String(value).trim() if (!text) return null const end = boundary === 'end' + const relative = parseRelativeDateOperand(text, boundary) + if (relative) return relative + if (/[-/T]/.test(text) || /\d:\d/.test(text)) { const parsed = new Date(text) if (Number.isNaN(parsed.getTime())) return null @@ -84,9 +281,46 @@ const parseDateOperand = (value, boundary = 'start') => { 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 + if (Number.isNaN(date.getTime())) return null + if (!dateComponentsMatch(date, year, month, day, hour, minute, second)) { + return null + } + return date } +const isValidDateOperand = (value) => parseDateOperand(value, 'start') != null + +const isValidDateFilterLeaf = (rawToken) => { + const token = rawToken.trim() + if (token === '') return true + + const rangeIdx = token.indexOf('..') + if (rangeIdx !== -1) { + const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim()) + const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim()) + if (lo !== '' && !isValidDateOperand(lo)) return false + if (hi !== '' && !isValidDateOperand(hi)) return false + return true + } + + for (const [symbol] of OPERATORS) { + if (token.startsWith(symbol)) { + const operand = stripIgnoreCase(token.slice(symbol.length).trim()) + return operand !== '' && isValidDateOperand(operand) + } + } + + return isValidDateOperand(stripIgnoreCase(token)) +} + +const isValidDateFilterNode = (node) => { + if (node.type === 'leaf') return isValidDateFilterLeaf(node.token) + return node.items.every(isValidDateFilterNode) +} + +export const isValidDateFilterExpression = (expression) => + isValidDateFilterNode(parseExpression(String(expression))) + const coerceScalar = (value) => { const lower = String(value).toLowerCase() if (lower === 'true') return true @@ -97,8 +331,7 @@ const coerceScalar = (value) => { const coerceBoundary = (value, isDateField, boundary) => { if (isDateField) { - const date = parseDateOperand(value, boundary) - if (date) return date + return parseDateOperand(value, boundary) } return coerceScalar(value) } @@ -377,6 +610,7 @@ export const matchesFilterExpression = (expression, candidate, fieldOptions = {} if (!text) return true try { const ast = parseExpression(text) + if (fieldOptions.isDateField && !isValidDateFilterNode(ast)) return true return evalNode(candidate, ast, fieldOptions) } catch { return false diff --git a/src/database/models/AuditLog.js b/src/database/models/AuditLog.js index 297d9dee..50d52003 100644 --- a/src/database/models/AuditLog.js +++ b/src/database/models/AuditLog.js @@ -33,7 +33,7 @@ export const AuditLog = { 'updatedAt', '_reference' ], - sorters: ['createdAt', 'updatedAt'], + sorters: ['createdAt', 'updatedAt', 'owner', 'parent', 'operation'], properties: [ { name: '_id',