Skip to content

Commit 0ef72f9

Browse files
fix(NewDatePicker): utilize local vs. UTC time zone (#6535)
* chore(NewDatePicker): add comment to why convert UTC to local * fix(NewDatePicker): persist the time range in UTC and convert the time range back to display format conditionally * chore(NewDatePicker): adjust time display when the selected time zone is utc * chore: add empty string for null or undefined value and add string type in function * test(sqlInterval): update test case to match time format * chore: remove time zone adjustment * chore: add comment * chore: export and reuse duration regular expression * chore: clear input error message when select a valid time range * test: add initial e2e test for new date picker * chore: run prettier * test: add tests for local and utc * chore: lint * test: update testID for timerange * chore: replace npm bin with npm exec * chore: add test for sql composition against different time zone * chore: update comment and lint * chore: convert time to ISO string instead of using hard coded time * test: add assertion for session storage value * chore: lint
1 parent 4c44325 commit 0ef72f9

File tree

5 files changed

+248
-56
lines changed

5 files changed

+248
-56
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// There are many different versions of date picker in UI.
2+
// This file covers the test for src/shared/components/dateRangePicker/NewDatePicker.tsx
3+
4+
describe('Date Picker', () => {
5+
before(() => cy.flush().then(() => cy.signin()))
6+
7+
beforeEach(() => {
8+
cy.scriptsLoginWithFlags({
9+
schemaComposition: true,
10+
}).then(() => {
11+
cy.clearSqlScriptSession()
12+
cy.getByTestID('editor-sync--toggle').should('have.class', 'active')
13+
})
14+
})
15+
16+
it('can select a duration', () => {
17+
cy.getByTestID('date-picker--menu').should('not.exist')
18+
cy.getByTestID('timerange-dropdown--button').should('be.visible').click()
19+
20+
cy.log('select a duration')
21+
cy.getByTestID('dropdown-item-past15m').click()
22+
cy.getByTestID('date-picker--menu').should('not.exist')
23+
24+
cy.log('dropdown button should display the selected duation')
25+
cy.getByTestID('timerange-dropdown--button')
26+
.should('be.visible')
27+
.contains('Past 15m')
28+
.should('have.length', 1)
29+
.click()
30+
31+
cy.log('input field "From" should show the corresponding duration')
32+
cy.getByTestID('date-picker--input--from')
33+
.should('be.visible')
34+
.should('have.value', '-15m')
35+
36+
cy.log('input field "To" should show "now()"')
37+
cy.getByTestID('date-picker--input--to')
38+
.should('be.visible')
39+
.should('have.value', 'now()')
40+
41+
cy.log('SQL composition should add the right expression for time')
42+
})
43+
44+
it('can set a duration', () => {
45+
const validDuration = '-1h'
46+
cy.getByTestID('timerange-dropdown--button').should('be.visible').click()
47+
48+
cy.log('error on empty start date')
49+
cy.getByTestID('date-picker--input--from').clear()
50+
cy.getByTestID('date-picker--input--from--error').should('exist')
51+
52+
cy.log('should error when invalid durations are input')
53+
cy.getByTestID('date-picker--input--from').type('invalid')
54+
cy.getByTestID('date-picker--input--from--error').should('exist')
55+
56+
cy.log('valid duration removes the error')
57+
cy.getByTestID('date-picker--input--from').clear().type(validDuration)
58+
cy.getByTestID('date-picker--input--from--error').should('not.exist')
59+
cy.getByTestID('daterange--apply-btn').should('be.enabled').click()
60+
61+
cy.getByTestID('timerange-dropdown--button')
62+
.should('be.visible')
63+
.contains(validDuration)
64+
})
65+
66+
it('can set a custom time', () => {
67+
const startTimeString: string = '2023-02-08 00:00'
68+
const startTimeDate: Date = new Date(startTimeString)
69+
const startTimeISOString: string = '2023-02-08T00:00:00.000Z'
70+
71+
cy.getByTestID('timerange-dropdown--button').should('be.visible').click()
72+
73+
cy.log('check calendar is available')
74+
cy.getByTestID('date-picker--calendar-icon').should('be.visible').click()
75+
cy.getByTestID('date-picker__select-date-picker').should('be.visible')
76+
cy.getByTestID('date-picker--calendar-icon').should('be.visible').click()
77+
cy.getByTestID('date-picker__select-date-picker').should('not.exist')
78+
79+
cy.log('input form should error for incomplete start times')
80+
cy.getByTestID('date-picker--input--from').clear().type('2023-10')
81+
cy.getByTestID('date-picker--input--from--error').should('exist')
82+
83+
cy.log('button should be disabled')
84+
cy.getByTestID('daterange--apply-btn').should('be.disabled')
85+
86+
cy.log('valid input removes the error')
87+
cy.getByTestID('date-picker--input--from').clear().type(startTimeString)
88+
cy.getByTestID('date-picker--input--from--error').should('not.exist')
89+
90+
cy.log(
91+
'when time zone is Local, dropdown button should display the custome time'
92+
)
93+
cy.getByTestID('timezone-dropdown').should('be.visible').click()
94+
cy.getByTestID('dropdown-item')
95+
.should('be.visible')
96+
.contains('Local')
97+
.click()
98+
cy.getByTestID('daterange--apply-btn').should('be.enabled').click()
99+
cy.getByTestID('timerange-dropdown--button')
100+
.should('be.visible')
101+
.contains(startTimeString)
102+
.then(() => {
103+
cy.log('session storage should persist time in UTC')
104+
cy.window()
105+
.its('sessionStorage')
106+
.invoke('getItem', 'dataExplorer.range')
107+
.then(sessionTimeRange => {
108+
cy.wrap(JSON.parse(sessionTimeRange || '')).should(
109+
'have.a.property',
110+
'lower',
111+
startTimeDate.toISOString()
112+
)
113+
})
114+
115+
cy.log('SQL composition should use standard UTC timestamp')
116+
cy.getByTestID('sql-editor').within(() => {
117+
cy.get('textarea.inputarea').should(
118+
'contain.value',
119+
startTimeDate.toISOString()
120+
)
121+
})
122+
})
123+
124+
cy.log(
125+
'when time zone is UTC, dropdown button should display the custome time'
126+
)
127+
cy.getByTestID('timerange-dropdown--button').should('be.visible').click()
128+
cy.getByTestID('timezone-dropdown').should('be.visible').click()
129+
cy.getByTestID('dropdown-item').should('be.visible').contains('UTC').click()
130+
cy.getByTestID('date-picker--input--from').clear().type(startTimeString)
131+
cy.getByTestID('daterange--apply-btn').should('be.enabled').click()
132+
cy.getByTestID('timerange-dropdown--button')
133+
.should('be.visible')
134+
.contains(startTimeString)
135+
.then(() => {
136+
cy.log('session storage should persist time in UTC')
137+
cy.window()
138+
.its('sessionStorage')
139+
.invoke('getItem', 'dataExplorer.range')
140+
.then(sessionTimeRange => {
141+
cy.wrap(JSON.parse(sessionTimeRange || '')).should(
142+
'have.a.property',
143+
'lower',
144+
startTimeISOString
145+
)
146+
})
147+
148+
cy.log('SQL composition should use standard UTC timestamp')
149+
cy.getByTestID('sql-editor').within(() => {
150+
cy.get('textarea.inputarea').should(
151+
'contain.value',
152+
startTimeISOString
153+
)
154+
})
155+
})
156+
157+
cy.getByTestID('date-picker--menu').should('not.exist')
158+
})
159+
})

cypress/e2e/shared/scriptQueryBuilder.results.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,7 @@ describe('Script Builder', () => {
112112
cy.wait('@query -1h')
113113

114114
cy.log('query date range can be adjusted')
115-
cy.getByTestID('timerange-dropdown').within(() => {
116-
cy.getByTestID('dropdown--button').should('exist')
117-
cy.getByTestID('dropdown--button').clickAttached()
118-
})
115+
cy.getByTestID('timerange-dropdown--button').should('be.visible').click()
119116
cy.getByTestID('dropdown-item-past15m').should('exist').click()
120117
cy.getByTestID('time-machine-submit-button').should('exist').click()
121118
cy.wait('@query -15m')
@@ -136,10 +133,9 @@ describe('Script Builder', () => {
136133
truncated: boolean
137134
) => {
138135
cy.log('confirm on 1hr')
139-
cy.getByTestID('timerange-dropdown').within(() => {
140-
cy.getByTestID('dropdown--button').should('exist')
141-
cy.getByTestID('dropdown--button').clickAttached()
142-
})
136+
cy.getByTestID('timerange-dropdown--button')
137+
.should('be.visible')
138+
.click()
143139
cy.getByTestID('dropdown-item-past1h').should('exist')
144140
cy.getByTestID('dropdown-item-past1h').clickAttached()
145141
cy.getByTestID('time-machine-submit-button').should('exist')

src/shared/components/dateRangePicker/NewDatePicker.tsx

Lines changed: 78 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,41 @@ import {PersistanceContext} from 'src/dataExplorer/context/persistance'
2222

2323
// Utils
2424
import {getTimeRangeLabel} from 'src/shared/utils/duration'
25+
import {isISODate} from 'src/shared/utils/dateTimeUtils'
26+
import {durationRegExp} from 'src/shared/utils/sqlInterval'
2527
import {SELECTABLE_TIME_RANGES} from 'src/shared/constants/timeRanges'
2628
import {useSelector} from 'react-redux'
2729
import {getTimeZone} from 'src/dashboards/selectors'
2830
import TimeZoneDropdown from 'src/shared/components/TimeZoneDropdown'
2931
import {isValidDatepickerFormat} from 'src/shared/components/dateRangePicker/utils'
30-
import {TimeRange} from 'src/types'
32+
import {TimeRange, TimeZone} from 'src/types'
3133

3234
const NBSP = '\u00a0\u00a0'
3335
const MAX_WIDTH_FOR_CUSTOM_TIMES = 325
3436
const FORMAT = 'YYYY-MM-DD HH:mm'
3537

38+
// if the input time is in ISO format: 'YYYY-MM-DDTHH:mm:ss.sssZ',
39+
// then convert it to 'YYYY-MM-DD HH:mm'
40+
const convertToDisplayFormat = (time: string, timeZone: TimeZone): string => {
41+
if (!time || !isISODate(time)) {
42+
return time
43+
}
44+
45+
if (timeZone !== 'UTC') {
46+
return moment(time).format(FORMAT)
47+
}
48+
49+
// since the data is in ISO format, and moment formats
50+
// to local time. so when our app is in UTC mode, to make
51+
// the moment formating respect that timezone, we have to
52+
// manually manipulate the time
53+
const convertedTime = new Date(time)
54+
convertedTime.setMinutes(
55+
convertedTime.getMinutes() + convertedTime.getTimezoneOffset()
56+
)
57+
return moment(convertedTime).format(FORMAT)
58+
}
59+
3660
interface Props {
3761
onCollapse: () => void
3862
timeRange: TimeRange
@@ -79,12 +103,10 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
79103
collapse()
80104
}
81105

82-
const durationRegExp = /([0-9]+)(y|mo|w|d|h|ms|s|m|us|µs|ns)$/g
83-
84-
const validateInput = value => {
106+
const validateInput = (value: string) => {
85107
return (
86108
isValidDatepickerFormat(value) ||
87-
!!value.match(durationRegExp) ||
109+
!!value?.match(durationRegExp) ||
88110
value === 'now()' ||
89111
isNaN(Number(value)) === false
90112
)
@@ -122,25 +144,16 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
122144

123145
const handleSelectDate = (dates: [Date, Date]): void => {
124146
const [start, end] = dates
147+
125148
// end should be EOD
126149
end && end.setMinutes(59)
127150
end && end.setHours(23)
151+
setDateRange([start, end])
128152

129153
// clone mutable objects
130154
const inputStart = new Date(start)
131155
const inputEnd = new Date(end)
132156

133-
// set local state
134-
if (timeZone === 'UTC') {
135-
if (start) {
136-
start.setMinutes(start.getMinutes() + start.getTimezoneOffset())
137-
}
138-
if (end) {
139-
end.setMinutes(end.getMinutes() + end.getTimezoneOffset())
140-
}
141-
}
142-
setDateRange([start, end])
143-
144157
// format for input display in DatePicker
145158
let startInput: string = null
146159
let endInput: string = null
@@ -151,6 +164,16 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
151164
if (end instanceof Date) {
152165
endInput = moment(inputEnd).format(FORMAT)
153166
}
167+
168+
// the start and end date should be valid by now
169+
// remove any error message if there is any
170+
if (inputStartErrorMessage !== NBSP) {
171+
setInputStartErrorMessage(NBSP)
172+
}
173+
if (inputEndErrorMessage !== NBSP) {
174+
setInputEndErrorMessage(NBSP)
175+
}
176+
154177
setInputStartDate(startInput)
155178
setInputEndDate(endInput)
156179
}
@@ -190,18 +213,30 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
190213
setInputEndDate(`${inputEndDate}ms`)
191214
}
192215
} else if (validateInput(inputStartDate)) {
193-
if (!inputEndDate) {
194-
setRange({
195-
lower: inputStartDate,
196-
upper: 'now()',
197-
type: 'custom',
198-
})
199-
resetCalendar()
200-
collapse()
201-
} else if (validateInput(inputEndDate)) {
216+
// persist custom time in ISO format:
217+
// 'YYYY-MM-DDTHH:mm:ss.sssZ', i.e. UTC time zone
218+
let lower: string = inputStartDate
219+
if (isValidDatepickerFormat(inputStartDate)) {
220+
const date =
221+
timeZone === 'UTC' && !isISODate(inputStartDate)
222+
? new Date(inputStartDate + 'Z')
223+
: new Date(inputStartDate)
224+
lower = date.toISOString()
225+
}
226+
227+
let upper: string = inputEndDate || 'now()'
228+
if (isValidDatepickerFormat(inputEndDate)) {
229+
const date =
230+
timeZone === 'UTC' && !isISODate(inputEndDate)
231+
? new Date(inputEndDate + 'Z')
232+
: new Date(inputEndDate)
233+
upper = date.toISOString()
234+
}
235+
236+
if (!inputEndDate || validateInput(inputEndDate)) {
202237
setRange({
203-
lower: inputStartDate,
204-
upper: inputEndDate,
238+
lower,
239+
upper,
205240
type: 'custom',
206241
})
207242
resetCalendar()
@@ -228,11 +263,15 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
228263
}}
229264
className="date-picker--menu"
230265
maxHeight={367}
266+
testID="date-picker--menu"
231267
>
232268
<FlexBox direction={FlexDirection.Row} alignItems={AlignItems.Stretch}>
233269
{isDatePickerOpen && (
234270
<div className="react-datepicker-ignore-onclickoutside date-picker--calendar-dropdown">
235-
<div className="date-picker__select-date-picker range-picker--date-pickers">
271+
<div
272+
className="date-picker__select-date-picker range-picker--date-pickers"
273+
data-testid="date-picker__select-date-picker"
274+
>
236275
<InputLabel className="date-picker--label__calendar">
237276
Calendar
238277
</InputLabel>
@@ -285,17 +324,23 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
285324
: ComponentStatus.Error
286325
}
287326
type={InputType.Text}
288-
value={inputStartDate ?? ''}
327+
value={convertToDisplayFormat(inputStartDate, timeZone) ?? ''}
328+
testID="date-picker--input--from"
289329
>
290330
<div
291331
className="date-picker--calendar-icon"
292332
onClick={handleOpenCalendar}
333+
data-testid="date-picker--calendar-icon"
293334
>
294335
<Icon glyph={IconFont.Calendar} />
295336
</div>
296337
</Input>
297338
</Form.Element>
298-
<Form.Element label="To" errorMessage={inputEndErrorMessage}>
339+
<Form.Element
340+
className="date-picker--form"
341+
label="To"
342+
errorMessage={inputEndErrorMessage}
343+
>
299344
<Input
300345
className="date-picker__input"
301346
onChange={handleSetEndDate}
@@ -305,7 +350,8 @@ const DatePickerMenu: FC<Props> = ({onCollapse, timeRange, timeRangeLabel}) => {
305350
: ComponentStatus.Error
306351
}
307352
type={InputType.Text}
308-
value={inputEndDate ?? ''}
353+
value={convertToDisplayFormat(inputEndDate, timeZone) ?? ''}
354+
testID="date-picker--input--to"
309355
>
310356
<div
311357
className="date-picker--calendar-icon"
@@ -398,6 +444,7 @@ const DatePicker: FC = () => {
398444
active={active}
399445
onClick={onClick}
400446
icon={IconFont.Clock_New}
447+
testID="timerange-dropdown--button"
401448
>
402449
{timeRangeLabel}
403450
</Dropdown.Button>

0 commit comments

Comments
 (0)