This is an automated email from the ASF dual-hosted git repository.
vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new f3d2dfb8d5f Web console: better ISO date parsing (#18724)
f3d2dfb8d5f is described below
commit f3d2dfb8d5fe3a072036a0b0cc82017ef594cef7
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Thu Nov 6 23:42:04 2025 +0000
Web console: better ISO date parsing (#18724)
* better ISO parsing
* prettify
---
web-console/src/utils/date.spec.ts | 152 +++++++++++++++++++++
web-console/src/utils/date.ts | 72 ++++++++++
.../filter-pane/filter-menu/filter-menu.tsx | 2 +-
.../time-interval-filter-control.tsx | 4 +-
.../components/iso-date-input/iso-date-input.tsx | 51 ++-----
5 files changed, 242 insertions(+), 39 deletions(-)
diff --git a/web-console/src/utils/date.spec.ts
b/web-console/src/utils/date.spec.ts
index b219ee17af0..fe21d9406b0 100644
--- a/web-console/src/utils/date.spec.ts
+++ b/web-console/src/utils/date.spec.ts
@@ -21,6 +21,7 @@ import {
intervalToLocalDateRange,
localDateRangeToInterval,
localToUtcDate,
+ parseIsoDate,
utcToLocalDate,
} from './date';
@@ -59,4 +60,155 @@ describe('date', () => {
expect(localDateRangeToInterval(intervalToLocalDateRange(interval))).toEqual(interval);
});
});
+
+ describe('parseIsoDate', () => {
+ it('works with year only', () => {
+ const result = parseIsoDate('2016');
+ expect(result).toEqual(new Date(Date.UTC(2016, 0, 1, 0, 0, 0, 0)));
+ });
+
+ it('works with year-month', () => {
+ const result = parseIsoDate('2016-06');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 1, 0, 0, 0, 0)));
+ });
+
+ it('works with date only', () => {
+ const result = parseIsoDate('2016-06-20');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 0, 0, 0, 0)));
+ });
+
+ it('works with date and hour using T separator', () => {
+ const result = parseIsoDate('2016-06-20T21');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 0, 0, 0)));
+ });
+
+ it('works with date and hour using space separator', () => {
+ const result = parseIsoDate('2016-06-20 21');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 0, 0, 0)));
+ });
+
+ it('works with date, hour, and minute using T separator', () => {
+ const result = parseIsoDate('2016-06-20T21:31');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 0, 0)));
+ });
+
+ it('works with date, hour, and minute using space separator', () => {
+ const result = parseIsoDate('2016-06-20 21:31');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 0, 0)));
+ });
+
+ it('works with datetime without milliseconds using T separator', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
+ });
+
+ it('works with datetime without milliseconds using space separator', () =>
{
+ const result = parseIsoDate('2016-06-20 21:31:02');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
+ });
+
+ it('works with full datetime with milliseconds using T separator', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02.123');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
+ });
+
+ it('works with full datetime with milliseconds using space separator', ()
=> {
+ const result = parseIsoDate('2016-06-20 21:31:02.123');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
+ });
+
+ it('works with single digit milliseconds', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02.1');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 100)));
+ });
+
+ it('works with two digit milliseconds', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02.12');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 120)));
+ });
+
+ it('works with whitespace trimming', () => {
+ const result = parseIsoDate(' 2016-06-20T21:31:02 ');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
+ });
+
+ it('works with trailing Z', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02Z');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
+ });
+
+ it('works with trailing Z and milliseconds', () => {
+ const result = parseIsoDate('2016-06-20T21:31:02.123Z');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
+ });
+
+ it('works with date only and trailing Z', () => {
+ const result = parseIsoDate('2016-06-20Z');
+ expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 0, 0, 0, 0)));
+ });
+
+ it('throws error for nonsense format with multiple T separators', () => {
+ expect(() => parseIsoDate('2016T06T20T21T31T02T000')).toThrow(
+ 'Invalid date format: expected ISO 8601 format',
+ );
+ });
+
+ it('throws error for invalid year below range', () => {
+ expect(() => parseIsoDate('0999-06-20')).toThrow(
+ 'Invalid year: must be between 1000 and 3999',
+ );
+ });
+
+ it('throws error for invalid year above range', () => {
+ expect(() => parseIsoDate('4000-06-20')).toThrow(
+ 'Invalid year: must be between 1000 and 3999',
+ );
+ });
+
+ it('throws error for invalid month below range', () => {
+ expect(() => parseIsoDate('2016-00-20')).toThrow('Invalid month: must be
between 1 and 12');
+ });
+
+ it('throws error for invalid month above range', () => {
+ expect(() => parseIsoDate('2016-13-20')).toThrow('Invalid month: must be
between 1 and 12');
+ });
+
+ it('throws error for invalid day below range', () => {
+ expect(() => parseIsoDate('2016-06-00')).toThrow('Invalid day: must be
between 1 and 31');
+ });
+
+ it('throws error for invalid day above range', () => {
+ expect(() => parseIsoDate('2016-06-32')).toThrow('Invalid day: must be
between 1 and 31');
+ });
+
+ it('throws error for invalid hour', () => {
+ expect(() => parseIsoDate('2016-06-20 25:00:00')).toThrow(
+ 'Invalid hour: must be between 0 and 23',
+ );
+ });
+
+ it('throws error for invalid minute', () => {
+ expect(() => parseIsoDate('2016-06-20 21:60:00')).toThrow(
+ 'Invalid minute: must be between 0 and 59',
+ );
+ });
+
+ it('throws error for invalid second', () => {
+ expect(() => parseIsoDate('2016-06-20 21:31:60')).toThrow(
+ 'Invalid second: must be between 0 and 59',
+ );
+ });
+
+ it('throws error for slash separators', () => {
+ expect(() => parseIsoDate('2016/06/20')).toThrow('Invalid date format');
+ });
+
+ it('throws error for completely invalid string', () => {
+ expect(() => parseIsoDate('not-a-date')).toThrow('Invalid date format');
+ });
+
+ it('throws error for empty string', () => {
+ expect(() => parseIsoDate('')).toThrow('Invalid date format');
+ });
+ });
});
diff --git a/web-console/src/utils/date.ts b/web-console/src/utils/date.ts
index 6121ab7a9c4..fd270e71d73 100644
--- a/web-console/src/utils/date.ts
+++ b/web-console/src/utils/date.ts
@@ -112,3 +112,75 @@ export function formatDate(value: string) {
return value;
}
}
+
+/**
+ * Parses an ISO 8601 date string into a Date object.
+ * Accepts flexible formats including:
+ * - Year only: "2016"
+ * - Year-month: "2016-06"
+ * - Date only: "2016-06-20"
+ * - Date with hour: "2016-06-20 21" or "2016-06-20T21"
+ * - Date with hour-minute: "2016-06-20 21:31" or "2016-06-20T21:31"
+ * - Date with hour-minute-second: "2016-06-20 21:31:02" or
"2016-06-20T21:31:02"
+ * - Full datetime: "2016-06-20T21:31:02.123" or "2016-06-20 21:31:02.123"
+ * - Optional trailing "Z": "2016-06-20T21:31:02Z" (the Z is ignored, date is
always parsed as UTC)
+ *
+ * Missing components default to: month=1, day=1, hour=0, minute=0, second=0,
millisecond=0
+ *
+ * @param dateString - The ISO date string to parse
+ * @returns A Date object in UTC
+ * @throws Error if the date string is invalid or components are out of range
+ */
+export function parseIsoDate(dateString: string): Date {
+ // Match ISO 8601 date format with optional date and time components and
optional trailing Z
+ // Format: YYYY[-MM[-DD[[T| ]HH[:mm[:ss[.SSS]]]]]][Z]
+ const isoRegex =
+ /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:[T
](\d{2})(?::(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?)?)?)?Z?$/;
+ const match = isoRegex.exec(dateString.trim());
+
+ if (!match) {
+ throw new Error(`Invalid date format: expected ISO 8601 format`);
+ }
+
+ const year = parseInt(match[1], 10);
+ const month = match[2] ? parseInt(match[2], 10) : 1;
+ const day = match[3] ? parseInt(match[3], 10) : 1;
+ const hour = match[4] ? parseInt(match[4], 10) : 0;
+ const minute = match[5] ? parseInt(match[5], 10) : 0;
+ const second = match[6] ? parseInt(match[6], 10) : 0;
+ const millisecond = match[7] ? parseInt(match[7].padEnd(3, '0'), 10) : 0;
+
+ // Validate year
+ if (year < 1000 || year > 3999) {
+ throw new Error(`Invalid year: must be between 1000 and 3999, got
${year}`);
+ }
+
+ // Validate month
+ if (month < 1 || month > 12) {
+ throw new Error(`Invalid month: must be between 1 and 12, got ${month}`);
+ }
+
+ // Validate day
+ if (day < 1 || day > 31) {
+ throw new Error(`Invalid day: must be between 1 and 31, got ${day}`);
+ }
+
+ // Validate time components
+ if (hour > 23) {
+ throw new Error(`Invalid hour: must be between 0 and 23, got ${hour}`);
+ }
+ if (minute > 59) {
+ throw new Error(`Invalid minute: must be between 0 and 59, got ${minute}`);
+ }
+ if (second > 59) {
+ throw new Error(`Invalid second: must be between 0 and 59, got ${second}`);
+ }
+
+ // Create UTC date
+ const value = Date.UTC(year, month - 1, day, hour, minute, second,
millisecond);
+ if (isNaN(value)) {
+ throw new Error(`Invalid date: the date components do not form a valid
date`);
+ }
+
+ return new Date(value);
+}
diff --git
a/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
index b885d9f05cd..3b737a4bea4 100644
---
a/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
+++
b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
@@ -432,7 +432,7 @@ export const FilterMenu = React.memo(function
FilterMenu(props: FilterMenuProps)
intent={Intent.PRIMARY}
text="Apply"
disabled={tab === 'sql' && formula === ''}
- data-tooltip={issue ? `Issue: ${issue}` : undefined}
+ data-tooltip={issue}
onClick={() => {
if (tab === 'compose') {
if (issue) {
diff --git
a/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
index 36bee870283..0c628c589d0 100644
---
a/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
+++
b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
@@ -63,7 +63,7 @@ export const TimeIntervalFilterControl = React.memo(function
TimeIntervalFilterC
setFilterPatternOrIssue(newPattern, undefined);
}
}}
- onIssue={() => onIssue('Bad start date')}
+ onIssue={issue => onIssue(`Bad start date: ${issue}`)}
/>
</FormGroup>
<FormGroup label="End">
@@ -79,7 +79,7 @@ export const TimeIntervalFilterControl = React.memo(function
TimeIntervalFilterC
setFilterPatternOrIssue(newPattern, undefined);
}
}}
- onIssue={() => onIssue('Bad end date')}
+ onIssue={issue => onIssue(`Bad end date: ${issue}`)}
/>
</FormGroup>
</div>
diff --git
a/web-console/src/views/explore-view/components/iso-date-input/iso-date-input.tsx
b/web-console/src/views/explore-view/components/iso-date-input/iso-date-input.tsx
index b145e31a221..8909d2c079c 100644
---
a/web-console/src/views/explore-view/components/iso-date-input/iso-date-input.tsx
+++
b/web-console/src/views/explore-view/components/iso-date-input/iso-date-input.tsx
@@ -19,41 +19,20 @@
import { InputGroup, Intent } from '@blueprintjs/core';
import { useState } from 'react';
-function isoParseDate(dateString: string): Date | undefined {
- const dateParts = dateString.split(/[-T:. ]/g);
-
- // Extract the individual date and time components
- const year = parseInt(dateParts[0], 10);
- if (!(1000 < year && year < 4000)) return;
-
- const month = parseInt(dateParts[1], 10);
- if (month > 12) return;
-
- const day = parseInt(dateParts[2], 10);
- if (day > 31) return;
-
- const hour = parseInt(dateParts[3], 10);
- if (hour > 23) return;
-
- const minute = parseInt(dateParts[4], 10);
- if (minute > 59) return;
-
- const second = parseInt(dateParts[5], 10);
- if (second > 59) return;
-
- const millisecond = parseInt(dateParts[6], 10);
- if (millisecond >= 1000) return;
-
- const value = Date.UTC(year, month - 1, day, hour, minute, second,
millisecond); // Month is zero-based
- if (isNaN(value)) return;
-
- return new Date(value);
-}
+import { parseIsoDate } from '../../../../utils';
function normalizeDateString(dateString: string): string {
return dateString.replace(/[^\-0-9T:./Z ]/g, '');
}
+function tryParseIsoDate(dateString: string): Date | undefined {
+ try {
+ return parseIsoDate(dateString);
+ } catch {
+ return undefined;
+ }
+}
+
function formatDate(date: Date): string {
return date.toISOString().replace(/Z$/, '').replace('.000',
'').replace(/T/g, ' ');
}
@@ -61,7 +40,7 @@ function formatDate(date: Date): string {
export interface UtcDateInputProps {
date: Date;
onChange(newDate: Date): void;
- onIssue(): void;
+ onIssue(issue: string): void;
}
export function IsoDateInput(props: UtcDateInputProps) {
@@ -77,20 +56,20 @@ export function IsoDateInput(props: UtcDateInputProps) {
intent={!focused && invalidDateString ? Intent.DANGER : undefined}
value={
invalidDateString ??
- (customDateString && isoParseDate(customDateString)?.valueOf() ===
date.valueOf()
+ (customDateString && tryParseIsoDate(customDateString)?.valueOf() ===
date.valueOf()
? customDateString
: undefined) ??
formatDate(date)
}
onChange={e => {
const normalizedDateString = normalizeDateString(e.target.value);
- const parsedDate = isoParseDate(normalizedDateString);
- if (parsedDate) {
+ try {
+ const parsedDate = parseIsoDate(normalizedDateString);
onChange(parsedDate);
setInvalidDateString(undefined);
setCustomDateString(normalizedDateString);
- } else {
- onIssue();
+ } catch (e) {
+ onIssue(e.message);
setInvalidDateString(normalizedDateString);
setCustomDateString(undefined);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]