Skip to content

Commit f3d2dfb

Browse files
authored
Web console: better ISO date parsing (#18724)
* better ISO parsing * prettify
1 parent a0b5d68 commit f3d2dfb

File tree

5 files changed

+242
-39
lines changed

5 files changed

+242
-39
lines changed

web-console/src/utils/date.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
intervalToLocalDateRange,
2222
localDateRangeToInterval,
2323
localToUtcDate,
24+
parseIsoDate,
2425
utcToLocalDate,
2526
} from './date';
2627

@@ -59,4 +60,155 @@ describe('date', () => {
5960
expect(localDateRangeToInterval(intervalToLocalDateRange(interval))).toEqual(interval);
6061
});
6162
});
63+
64+
describe('parseIsoDate', () => {
65+
it('works with year only', () => {
66+
const result = parseIsoDate('2016');
67+
expect(result).toEqual(new Date(Date.UTC(2016, 0, 1, 0, 0, 0, 0)));
68+
});
69+
70+
it('works with year-month', () => {
71+
const result = parseIsoDate('2016-06');
72+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 1, 0, 0, 0, 0)));
73+
});
74+
75+
it('works with date only', () => {
76+
const result = parseIsoDate('2016-06-20');
77+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 0, 0, 0, 0)));
78+
});
79+
80+
it('works with date and hour using T separator', () => {
81+
const result = parseIsoDate('2016-06-20T21');
82+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 0, 0, 0)));
83+
});
84+
85+
it('works with date and hour using space separator', () => {
86+
const result = parseIsoDate('2016-06-20 21');
87+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 0, 0, 0)));
88+
});
89+
90+
it('works with date, hour, and minute using T separator', () => {
91+
const result = parseIsoDate('2016-06-20T21:31');
92+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 0, 0)));
93+
});
94+
95+
it('works with date, hour, and minute using space separator', () => {
96+
const result = parseIsoDate('2016-06-20 21:31');
97+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 0, 0)));
98+
});
99+
100+
it('works with datetime without milliseconds using T separator', () => {
101+
const result = parseIsoDate('2016-06-20T21:31:02');
102+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
103+
});
104+
105+
it('works with datetime without milliseconds using space separator', () => {
106+
const result = parseIsoDate('2016-06-20 21:31:02');
107+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
108+
});
109+
110+
it('works with full datetime with milliseconds using T separator', () => {
111+
const result = parseIsoDate('2016-06-20T21:31:02.123');
112+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
113+
});
114+
115+
it('works with full datetime with milliseconds using space separator', () => {
116+
const result = parseIsoDate('2016-06-20 21:31:02.123');
117+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
118+
});
119+
120+
it('works with single digit milliseconds', () => {
121+
const result = parseIsoDate('2016-06-20T21:31:02.1');
122+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 100)));
123+
});
124+
125+
it('works with two digit milliseconds', () => {
126+
const result = parseIsoDate('2016-06-20T21:31:02.12');
127+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 120)));
128+
});
129+
130+
it('works with whitespace trimming', () => {
131+
const result = parseIsoDate(' 2016-06-20T21:31:02 ');
132+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
133+
});
134+
135+
it('works with trailing Z', () => {
136+
const result = parseIsoDate('2016-06-20T21:31:02Z');
137+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 0)));
138+
});
139+
140+
it('works with trailing Z and milliseconds', () => {
141+
const result = parseIsoDate('2016-06-20T21:31:02.123Z');
142+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 21, 31, 2, 123)));
143+
});
144+
145+
it('works with date only and trailing Z', () => {
146+
const result = parseIsoDate('2016-06-20Z');
147+
expect(result).toEqual(new Date(Date.UTC(2016, 5, 20, 0, 0, 0, 0)));
148+
});
149+
150+
it('throws error for nonsense format with multiple T separators', () => {
151+
expect(() => parseIsoDate('2016T06T20T21T31T02T000')).toThrow(
152+
'Invalid date format: expected ISO 8601 format',
153+
);
154+
});
155+
156+
it('throws error for invalid year below range', () => {
157+
expect(() => parseIsoDate('0999-06-20')).toThrow(
158+
'Invalid year: must be between 1000 and 3999',
159+
);
160+
});
161+
162+
it('throws error for invalid year above range', () => {
163+
expect(() => parseIsoDate('4000-06-20')).toThrow(
164+
'Invalid year: must be between 1000 and 3999',
165+
);
166+
});
167+
168+
it('throws error for invalid month below range', () => {
169+
expect(() => parseIsoDate('2016-00-20')).toThrow('Invalid month: must be between 1 and 12');
170+
});
171+
172+
it('throws error for invalid month above range', () => {
173+
expect(() => parseIsoDate('2016-13-20')).toThrow('Invalid month: must be between 1 and 12');
174+
});
175+
176+
it('throws error for invalid day below range', () => {
177+
expect(() => parseIsoDate('2016-06-00')).toThrow('Invalid day: must be between 1 and 31');
178+
});
179+
180+
it('throws error for invalid day above range', () => {
181+
expect(() => parseIsoDate('2016-06-32')).toThrow('Invalid day: must be between 1 and 31');
182+
});
183+
184+
it('throws error for invalid hour', () => {
185+
expect(() => parseIsoDate('2016-06-20 25:00:00')).toThrow(
186+
'Invalid hour: must be between 0 and 23',
187+
);
188+
});
189+
190+
it('throws error for invalid minute', () => {
191+
expect(() => parseIsoDate('2016-06-20 21:60:00')).toThrow(
192+
'Invalid minute: must be between 0 and 59',
193+
);
194+
});
195+
196+
it('throws error for invalid second', () => {
197+
expect(() => parseIsoDate('2016-06-20 21:31:60')).toThrow(
198+
'Invalid second: must be between 0 and 59',
199+
);
200+
});
201+
202+
it('throws error for slash separators', () => {
203+
expect(() => parseIsoDate('2016/06/20')).toThrow('Invalid date format');
204+
});
205+
206+
it('throws error for completely invalid string', () => {
207+
expect(() => parseIsoDate('not-a-date')).toThrow('Invalid date format');
208+
});
209+
210+
it('throws error for empty string', () => {
211+
expect(() => parseIsoDate('')).toThrow('Invalid date format');
212+
});
213+
});
62214
});

web-console/src/utils/date.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,75 @@ export function formatDate(value: string) {
112112
return value;
113113
}
114114
}
115+
116+
/**
117+
* Parses an ISO 8601 date string into a Date object.
118+
* Accepts flexible formats including:
119+
* - Year only: "2016"
120+
* - Year-month: "2016-06"
121+
* - Date only: "2016-06-20"
122+
* - Date with hour: "2016-06-20 21" or "2016-06-20T21"
123+
* - Date with hour-minute: "2016-06-20 21:31" or "2016-06-20T21:31"
124+
* - Date with hour-minute-second: "2016-06-20 21:31:02" or "2016-06-20T21:31:02"
125+
* - Full datetime: "2016-06-20T21:31:02.123" or "2016-06-20 21:31:02.123"
126+
* - Optional trailing "Z": "2016-06-20T21:31:02Z" (the Z is ignored, date is always parsed as UTC)
127+
*
128+
* Missing components default to: month=1, day=1, hour=0, minute=0, second=0, millisecond=0
129+
*
130+
* @param dateString - The ISO date string to parse
131+
* @returns A Date object in UTC
132+
* @throws Error if the date string is invalid or components are out of range
133+
*/
134+
export function parseIsoDate(dateString: string): Date {
135+
// Match ISO 8601 date format with optional date and time components and optional trailing Z
136+
// Format: YYYY[-MM[-DD[[T| ]HH[:mm[:ss[.SSS]]]]]][Z]
137+
const isoRegex =
138+
/^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:[T ](\d{2})(?::(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?)?)?)?Z?$/;
139+
const match = isoRegex.exec(dateString.trim());
140+
141+
if (!match) {
142+
throw new Error(`Invalid date format: expected ISO 8601 format`);
143+
}
144+
145+
const year = parseInt(match[1], 10);
146+
const month = match[2] ? parseInt(match[2], 10) : 1;
147+
const day = match[3] ? parseInt(match[3], 10) : 1;
148+
const hour = match[4] ? parseInt(match[4], 10) : 0;
149+
const minute = match[5] ? parseInt(match[5], 10) : 0;
150+
const second = match[6] ? parseInt(match[6], 10) : 0;
151+
const millisecond = match[7] ? parseInt(match[7].padEnd(3, '0'), 10) : 0;
152+
153+
// Validate year
154+
if (year < 1000 || year > 3999) {
155+
throw new Error(`Invalid year: must be between 1000 and 3999, got ${year}`);
156+
}
157+
158+
// Validate month
159+
if (month < 1 || month > 12) {
160+
throw new Error(`Invalid month: must be between 1 and 12, got ${month}`);
161+
}
162+
163+
// Validate day
164+
if (day < 1 || day > 31) {
165+
throw new Error(`Invalid day: must be between 1 and 31, got ${day}`);
166+
}
167+
168+
// Validate time components
169+
if (hour > 23) {
170+
throw new Error(`Invalid hour: must be between 0 and 23, got ${hour}`);
171+
}
172+
if (minute > 59) {
173+
throw new Error(`Invalid minute: must be between 0 and 59, got ${minute}`);
174+
}
175+
if (second > 59) {
176+
throw new Error(`Invalid second: must be between 0 and 59, got ${second}`);
177+
}
178+
179+
// Create UTC date
180+
const value = Date.UTC(year, month - 1, day, hour, minute, second, millisecond);
181+
if (isNaN(value)) {
182+
throw new Error(`Invalid date: the date components do not form a valid date`);
183+
}
184+
185+
return new Date(value);
186+
}

web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
432432
intent={Intent.PRIMARY}
433433
text="Apply"
434434
disabled={tab === 'sql' && formula === ''}
435-
data-tooltip={issue ? `Issue: ${issue}` : undefined}
435+
data-tooltip={issue}
436436
onClick={() => {
437437
if (tab === 'compose') {
438438
if (issue) {

web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterC
6363
setFilterPatternOrIssue(newPattern, undefined);
6464
}
6565
}}
66-
onIssue={() => onIssue('Bad start date')}
66+
onIssue={issue => onIssue(`Bad start date: ${issue}`)}
6767
/>
6868
</FormGroup>
6969
<FormGroup label="End">
@@ -79,7 +79,7 @@ export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterC
7979
setFilterPatternOrIssue(newPattern, undefined);
8080
}
8181
}}
82-
onIssue={() => onIssue('Bad end date')}
82+
onIssue={issue => onIssue(`Bad end date: ${issue}`)}
8383
/>
8484
</FormGroup>
8585
</div>

web-console/src/views/explore-view/components/iso-date-input/iso-date-input.tsx

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,49 +19,28 @@
1919
import { InputGroup, Intent } from '@blueprintjs/core';
2020
import { useState } from 'react';
2121

22-
function isoParseDate(dateString: string): Date | undefined {
23-
const dateParts = dateString.split(/[-T:. ]/g);
24-
25-
// Extract the individual date and time components
26-
const year = parseInt(dateParts[0], 10);
27-
if (!(1000 < year && year < 4000)) return;
28-
29-
const month = parseInt(dateParts[1], 10);
30-
if (month > 12) return;
31-
32-
const day = parseInt(dateParts[2], 10);
33-
if (day > 31) return;
34-
35-
const hour = parseInt(dateParts[3], 10);
36-
if (hour > 23) return;
37-
38-
const minute = parseInt(dateParts[4], 10);
39-
if (minute > 59) return;
40-
41-
const second = parseInt(dateParts[5], 10);
42-
if (second > 59) return;
43-
44-
const millisecond = parseInt(dateParts[6], 10);
45-
if (millisecond >= 1000) return;
46-
47-
const value = Date.UTC(year, month - 1, day, hour, minute, second, millisecond); // Month is zero-based
48-
if (isNaN(value)) return;
49-
50-
return new Date(value);
51-
}
22+
import { parseIsoDate } from '../../../../utils';
5223

5324
function normalizeDateString(dateString: string): string {
5425
return dateString.replace(/[^\-0-9T:./Z ]/g, '');
5526
}
5627

28+
function tryParseIsoDate(dateString: string): Date | undefined {
29+
try {
30+
return parseIsoDate(dateString);
31+
} catch {
32+
return undefined;
33+
}
34+
}
35+
5736
function formatDate(date: Date): string {
5837
return date.toISOString().replace(/Z$/, '').replace('.000', '').replace(/T/g, ' ');
5938
}
6039

6140
export interface UtcDateInputProps {
6241
date: Date;
6342
onChange(newDate: Date): void;
64-
onIssue(): void;
43+
onIssue(issue: string): void;
6544
}
6645

6746
export function IsoDateInput(props: UtcDateInputProps) {
@@ -77,20 +56,20 @@ export function IsoDateInput(props: UtcDateInputProps) {
7756
intent={!focused && invalidDateString ? Intent.DANGER : undefined}
7857
value={
7958
invalidDateString ??
80-
(customDateString && isoParseDate(customDateString)?.valueOf() === date.valueOf()
59+
(customDateString && tryParseIsoDate(customDateString)?.valueOf() === date.valueOf()
8160
? customDateString
8261
: undefined) ??
8362
formatDate(date)
8463
}
8564
onChange={e => {
8665
const normalizedDateString = normalizeDateString(e.target.value);
87-
const parsedDate = isoParseDate(normalizedDateString);
88-
if (parsedDate) {
66+
try {
67+
const parsedDate = parseIsoDate(normalizedDateString);
8968
onChange(parsedDate);
9069
setInvalidDateString(undefined);
9170
setCustomDateString(normalizedDateString);
92-
} else {
93-
onIssue();
71+
} catch (e) {
72+
onIssue(e.message);
9473
setInvalidDateString(normalizedDateString);
9574
setCustomDateString(undefined);
9675
}

0 commit comments

Comments
 (0)