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]

Reply via email to