Skip to content

Commit c910d20

Browse files
authored
Refactor DateTimeRangePicker again (#1247)
* hella basic refetch interval control * do it on the metrics page instead * get rid of refetch interval in favor of hard-coded 10s interval * fix up bad behavior due to not clearing interval when changing preset * all right forget the immediate: true thing * useDateTimeRange * go back to the hook model * don't do the refetch buttons in this branch
1 parent 6a8ee0d commit c910d20

File tree

7 files changed

+112
-114
lines changed

7 files changed

+112
-114
lines changed

app/components/form/fields/DateTimeRangePicker.spec.tsx

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,27 @@ import { DateTimeRangePicker, dateForInput } from './DateTimeRangePicker'
1010
const now = new Date(2020, 1, 1)
1111

1212
function renderLastDay() {
13-
const onChange = vi.fn()
13+
const setStartTime = vi.fn()
14+
const setEndTime = vi.fn()
1415
render(
1516
<DateTimeRangePicker
1617
initialPreset="lastDay"
1718
startTime={subDays(now, 1)}
1819
endTime={now}
19-
onChange={onChange}
20+
setStartTime={setStartTime}
21+
setEndTime={setEndTime}
2022
/>
2123
)
22-
return { onChange }
24+
return { setStartTime, setEndTime }
2325
}
2426

27+
beforeAll(() => {
28+
vi.useFakeTimers()
29+
vi.setSystemTime(now)
30+
31+
return () => vi.useRealTimers()
32+
})
33+
2534
describe('useDateTimeRangePicker', () => {
2635
it.each([
2736
['lastHour', subHours(now, 1)],
@@ -30,13 +39,13 @@ describe('useDateTimeRangePicker', () => {
3039
['lastWeek', subDays(now, 7)],
3140
['last30Days', subDays(now, 30)],
3241
])('sets initial start and end', (preset, start) => {
33-
const onChange = vi.fn()
3442
render(
3543
<DateTimeRangePicker
3644
initialPreset={preset as RangeKey}
3745
startTime={start}
3846
endTime={now}
39-
onChange={onChange}
47+
setStartTime={() => {}}
48+
setEndTime={() => {}}
4049
/>
4150
)
4251

@@ -52,36 +61,33 @@ it.each([
5261
['Last week', subDays(now, 7)],
5362
['Last 30 days', subDays(now, 30)],
5463
])('choosing a preset sets the times', (option, start) => {
55-
vi.useFakeTimers()
56-
vi.setSystemTime(now)
57-
58-
const { onChange } = renderLastDay()
64+
const { setStartTime, setEndTime } = renderLastDay()
5965

6066
clickByRole('button', 'Choose a time range')
6167
clickByRole('option', option)
6268

63-
expect(onChange).toBeCalledWith(start, now)
64-
65-
vi.useRealTimers()
69+
expect(setStartTime).toBeCalledWith(start)
70+
expect(setEndTime).toBeCalledWith(now)
6671
})
6772

6873
describe('custom mode', () => {
6974
it('enables datetime inputs', () => {
70-
const { onChange } = renderLastDay()
75+
const { setStartTime, setEndTime } = renderLastDay()
7176

7277
expect(screen.getByLabelText('Start time')).toBeDisabled()
7378

7479
clickByRole('button', 'Choose a time range')
7580
clickByRole('option', 'Custom...')
7681

77-
expect(onChange).not.toBeCalled()
82+
expect(setStartTime).not.toBeCalled()
83+
expect(setEndTime).not.toBeCalled()
7884
expect(screen.getByLabelText('Start time')).toBeEnabled()
7985
expect(screen.getByRole('button', { name: 'Reset' })).toHaveClass('visually-disabled')
8086
expect(screen.getByRole('button', { name: 'Load' })).toHaveClass('visually-disabled')
8187
})
8288

8389
it('clicking load after changing date changes range', async () => {
84-
const { onChange } = renderLastDay()
90+
const { setStartTime, setEndTime } = renderLastDay()
8591

8692
expect(screen.getByLabelText('Start time')).toHaveValue(dateForInput(subDays(now, 1)))
8793
expect(screen.getByLabelText('End time')).toHaveValue(dateForInput(now))
@@ -98,15 +104,17 @@ describe('custom mode', () => {
98104
fireEvent.change(endInput, { target: { value: '2020-01-17T00:00' } })
99105

100106
// changing the input value without clicking Load doesn't do anything
101-
expect(onChange).not.toBeCalled()
107+
expect(setStartTime).not.toBeCalled()
108+
expect(setEndTime).not.toBeCalled()
102109

103-
// clicking Load calls onChange
110+
// clicking Load calls setTime with the new range
104111
clickByRole('button', 'Load')
105-
expect(onChange).toBeCalledWith(new Date(2020, 0, 15), new Date(2020, 0, 17))
112+
expect(setStartTime).toBeCalledWith(new Date(2020, 0, 15))
113+
expect(setEndTime).toBeCalledWith(new Date(2020, 0, 17))
106114
})
107115

108116
it('clicking reset after changing inputs resets inputs', async () => {
109-
const { onChange } = renderLastDay()
117+
const { setStartTime, setEndTime } = renderLastDay()
110118

111119
expect(screen.getByLabelText('Start time')).toHaveValue(dateForInput(subDays(now, 1)))
112120
expect(screen.getByLabelText('End time')).toHaveValue(dateForInput(now))
@@ -127,8 +135,8 @@ describe('custom mode', () => {
127135
expect(startInput).toHaveValue('2020-01-31T00:00')
128136
expect(endInput).toHaveValue('2020-02-01T00:00')
129137

130-
// onChange is never called
131-
expect(onChange).not.toBeCalled()
138+
expect(setStartTime).not.toBeCalled()
139+
expect(setEndTime).not.toBeCalled()
132140
})
133141

134142
it('shows error for invalid range', () => {

app/components/form/fields/DateTimeRangePicker.tsx

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { format, subDays, subHours } from 'date-fns'
2-
import { useMemo, useState } from 'react'
2+
import { useCallback, useMemo, useState } from 'react'
33

44
import { Listbox, useInterval } from '@oxide/ui'
55
import { Button, TextInput } from '@oxide/ui'
@@ -32,31 +32,25 @@ const computeStart: Record<RangeKey, (now: Date) => Date> = {
3232
// - list of presets is hard-coded
3333
// - initial preset can't be "custom"
3434

35-
type Props = {
36-
initialPreset: RangeKey
37-
/**
38-
* if set and range is a relative preset, update the range to have `endTime`
39-
* of now every X ms
40-
*/
41-
slideInterval?: number
42-
startTime: Date
43-
endTime: Date
44-
onChange: (startTime: Date, endTime: Date) => void
45-
}
46-
47-
export function useDateTimeRangePickerState(initialPreset: RangeKey) {
48-
// default endTime is now, i.e., mount time
35+
/**
36+
* Exposes `startTime` and `endTime` plus the whole set of picker UI controls as
37+
* a JSX element to render. When we're using a relative preset like last N
38+
* hours, automatically slide the window forward live by updating the range to
39+
* have `endTime` of _now_ every `SLIDE_INTERVAL` ms.
40+
*/
41+
export function useDateTimeRangePicker(initialPreset: RangeKey) {
4942
const now = useMemo(() => new Date(), [])
5043

5144
const [startTime, setStartTime] = useState(computeStart[initialPreset](now))
5245
const [endTime, setEndTime] = useState(now)
5346

54-
function onChange(newStart: Date, newEnd: Date) {
55-
setStartTime(newStart)
56-
setEndTime(newEnd)
57-
}
47+
const props = { initialPreset, startTime, endTime, setStartTime, setEndTime }
5848

59-
return { startTime, endTime, onChange }
49+
return {
50+
startTime,
51+
endTime,
52+
dateTimeRangePicker: <DateTimeRangePicker {...props} />,
53+
}
6054
}
6155

6256
function validateRange(startTime: Date, endTime: Date): string | null {
@@ -67,17 +61,24 @@ function validateRange(startTime: Date, endTime: Date): string | null {
6761
return null
6862
}
6963

70-
/**
71-
* Exposes `startTime` and `endTime` plus the whole set of picker UI controls as
72-
* a JSX element to render.
73-
*/
64+
/** Interval for sliding range forward when using a relative time preset */
65+
const SLIDE_INTERVAL = 10_000
66+
67+
type DateTimeRangePickerProps = {
68+
initialPreset: RangeKey
69+
startTime: Date
70+
endTime: Date
71+
setStartTime: (startTime: Date) => void
72+
setEndTime: (endTime: Date) => void
73+
}
74+
7475
export function DateTimeRangePicker({
7576
initialPreset,
76-
slideInterval,
7777
startTime,
7878
endTime,
79-
onChange,
80-
}: Props) {
79+
setStartTime,
80+
setEndTime,
81+
}: DateTimeRangePickerProps) {
8182
const [preset, setPreset] = useState<RangeKeyAll>(initialPreset)
8283

8384
// needs a separate pair of values because they can be edited without
@@ -92,22 +93,29 @@ export function DateTimeRangePicker({
9293

9394
const enableInputs = preset === 'custom'
9495

95-
/** Set the input values and call the passed-on onChange with the new times */
96-
function setTimesForPreset(newPreset: RangeKey) {
97-
const now = new Date()
98-
const newStartTime = computeStart[newPreset](now)
99-
onChange(newStartTime, now)
100-
setStartTimeInput(newStartTime)
101-
setEndTimeInput(now)
102-
}
103-
104-
useInterval(
105-
() => {
106-
if (preset !== 'custom') setTimesForPreset(preset)
96+
// could handle this in a useEffect that looks at `preset`, but that would
97+
// also run on initial render, which is silly. Instead explicitly call it on
98+
// preset change and in useInterval.
99+
const setRange = useCallback(
100+
(preset: RangeKeyAll) => {
101+
if (preset !== 'custom') {
102+
const now = new Date()
103+
const newStartTime = computeStart[preset](now)
104+
setStartTime(newStartTime)
105+
setEndTime(now)
106+
setStartTimeInput(newStartTime)
107+
setEndTimeInput(now)
108+
}
107109
},
108-
slideInterval && preset !== 'custom' ? slideInterval : null
110+
[setStartTime, setEndTime]
109111
)
110112

113+
useInterval({
114+
fn: () => setRange(preset),
115+
delay: preset !== 'custom' ? SLIDE_INTERVAL : null,
116+
key: preset, // force a render which clears current interval
117+
})
118+
111119
return (
112120
<form className="flex h-20 gap-4">
113121
<Listbox
@@ -118,8 +126,9 @@ export function DateTimeRangePicker({
118126
items={rangePresets}
119127
onChange={(item) => {
120128
if (item) {
121-
setPreset(item.value as RangeKeyAll)
122-
if (item.value !== 'custom') setTimesForPreset(item.value as RangeKey)
129+
const newPreset = item.value as RangeKeyAll
130+
setPreset(newPreset)
131+
setRange(newPreset)
123132
}
124133
}}
125134
/>
@@ -170,7 +179,10 @@ export function DateTimeRangePicker({
170179
{enableInputs && (
171180
<Button
172181
disabled={!customInputsDirty || !!error}
173-
onClick={() => onChange(startTimeInput, endTimeInput)}
182+
onClick={() => {
183+
setStartTime(startTimeInput)
184+
setEndTime(endTimeInput)
185+
}}
174186
>
175187
Load
176188
</Button>

app/pages/SiloUtilizationPage.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide
55
import { bytesToGiB } from '@oxide/util'
66

77
import { SystemMetric } from 'app/components/SystemMetric'
8-
import { DateTimeRangePicker, useDateTimeRangePickerState } from 'app/components/form'
8+
import { useDateTimeRangePicker } from 'app/components/form'
99

1010
const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000'
1111
const ALL_PROJECTS = '|ALL_PROJECTS|'
@@ -32,12 +32,7 @@ export function SiloUtilizationPage() {
3232
{ enabled: !!orgName }
3333
)
3434

35-
const initialPreset = 'lastHour'
36-
const {
37-
startTime,
38-
endTime,
39-
onChange: onTimeChange,
40-
} = useDateTimeRangePickerState(initialPreset)
35+
const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastHour')
4136

4237
const orgItems = useMemo(() => {
4338
const items = orgs?.items.map(toListboxItem) || []
@@ -109,13 +104,7 @@ export function SiloUtilizationPage() {
109104
)}
110105
</div>
111106

112-
<DateTimeRangePicker
113-
initialPreset={initialPreset}
114-
slideInterval={5000}
115-
startTime={startTime}
116-
endTime={endTime}
117-
onChange={onTimeChange}
118-
/>
107+
{dateTimeRangePicker}
119108
</div>
120109
{/* TODO: this divider is supposed to go all the way across */}
121110
<Divider className="mb-6" />

app/pages/project/instances/instance/tabs/MetricsTab.tsx

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useApiQuery } from '@oxide/api'
66
import { Listbox, Spinner } from '@oxide/ui'
77

88
import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart'
9-
import { DateTimeRangePicker, useDateTimeRangePickerState } from 'app/components/form'
9+
import { useDateTimeRangePicker } from 'app/components/form'
1010
import { useRequiredParams } from 'app/hooks'
1111

1212
type DiskMetricParams = {
@@ -69,12 +69,7 @@ function DiskMetric({
6969
function DiskMetrics({ disks }: { disks: Disk[] }) {
7070
const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
7171

72-
const initialPreset = 'lastDay'
73-
const {
74-
startTime,
75-
endTime,
76-
onChange: onTimeChange,
77-
} = useDateTimeRangePickerState(initialPreset)
72+
const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay')
7873

7974
invariant(disks.length > 0, 'DiskMetrics should not be rendered with zero disks')
8075
const [diskName, setDiskName] = useState<string>(disks[0].name)
@@ -86,9 +81,6 @@ function DiskMetrics({ disks }: { disks: Disk[] }) {
8681
return (
8782
<>
8883
<div className="mt-8 mb-4 flex justify-between">
89-
{/* TODO: using a Formik field here feels like overkill, but we've built
90-
ListboxField to require that, i.e., there's no way to get the nice worked-out
91-
layout from ListboxField without using Formik. Something to think about. */}
9284
<Listbox
9385
className="w-48"
9486
aria-label="Choose disk"
@@ -101,13 +93,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) {
10193
}}
10294
defaultValue={diskName}
10395
/>
104-
<DateTimeRangePicker
105-
initialPreset={initialPreset}
106-
slideInterval={5000}
107-
startTime={startTime}
108-
endTime={endTime}
109-
onChange={onTimeChange}
110-
/>
96+
{dateTimeRangePicker}
11197
</div>
11298

11399
{/* TODO: separate "Reads" from "(count)" so we can

app/pages/system/CapacityUtilizationPage.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide
55
import { bytesToGiB } from '@oxide/util'
66

77
import { SystemMetric } from 'app/components/SystemMetric'
8-
import { DateTimeRangePicker, useDateTimeRangePickerState } from 'app/components/form'
8+
import { useDateTimeRangePicker } from 'app/components/form'
99

1010
const FLEET_ID = '001de000-1334-4000-8000-000000000000'
1111
const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000'
@@ -20,11 +20,7 @@ export function CapacityUtilizationPage() {
2020
const { data: silos } = useApiQuery('siloList', {})
2121

2222
const initialPreset = 'lastHour'
23-
const {
24-
startTime,
25-
endTime,
26-
onChange: onTimeChange,
27-
} = useDateTimeRangePickerState(initialPreset)
23+
const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker(initialPreset)
2824

2925
const siloItems = useMemo(() => {
3026
const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || []
@@ -66,13 +62,7 @@ export function CapacityUtilizationPage() {
6662
{/* TODO: need a button to clear the silo */}
6763
</div>
6864

69-
<DateTimeRangePicker
70-
initialPreset={initialPreset}
71-
slideInterval={5000}
72-
startTime={startTime}
73-
endTime={endTime}
74-
onChange={onTimeChange}
75-
/>
65+
{dateTimeRangePicker}
7666
</div>
7767
{/* TODO: this divider is supposed to go all the way across */}
7868
<Divider className="mb-6" />

0 commit comments

Comments
 (0)