Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
DatePickerPopup,
DatePickerPopupProps,
} from "@/ui/DatePickerPopup/DatePickerPopup"
import { isSameDatePickerValue } from "@/ui/DatePickerPopup/utils"
import {
isSameDatePickerValue,
reviveDatePickerValue,
} from "@/ui/DatePickerPopup/utils"

import { getGranularityDefinitions } from "@/components/OneCalendar/granularities"
import {
Expand Down Expand Up @@ -37,17 +40,28 @@ function _OneDateNavigator({
dataTestId,
...props
}: OneDatePickerProps) {
// A `value`/`defaultValue` restored from persisted storage (e.g. a
// OneDataCollection `date-navigator` filter) arrives with `from`/`to` as
// strings after its JSON round-trip. Revive them to `Date` at the boundary so
// every downstream consumer — the equality check below, the trigger label,
// granularity math — receives real `Date` objects.
const revivedValue = useMemo(() => reviveDatePickerValue(value), [value])
const revivedDefaultValue = useMemo(
() => reviveDatePickerValue(defaultValue),
[defaultValue]
)

const [localValue, setLocalValue] = useState<DatePickerValue | undefined>(
defaultValue ?? value
revivedDefaultValue ?? revivedValue
)

useEffect(() => {
if (isSameDatePickerValue(value, localValue)) {
if (isSameDatePickerValue(revivedValue, localValue)) {
return
}
setLocalValue(value || defaultValue)
setLocalValue(revivedValue || revivedDefaultValue)
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to update the local value when the value changes
}, [value, defaultValue])
}, [revivedValue, revivedDefaultValue])

const [compareToValue, setCompareToValue] = useState<
DateRangeComplete | DateRangeComplete[] | undefined
Expand Down Expand Up @@ -88,7 +102,7 @@ function _OneDateNavigator({
<DatePickerPopup
onSelect={handleSelect}
value={localValue}
defaultValue={defaultValue}
defaultValue={revivedDefaultValue}
presets={presets}
granularities={granularities}
minDate={props.minDate}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,49 @@ describe("OneDateNavigator", () => {
expect(previousButton).toBeNull()
})

// Regression: a value restored from persisted OneDataCollection storage has
// round-tripped through JSON, so its `from`/`to` are ISO strings rather than
// Date objects. The navigator used to call `.getTime()` on them and throw
// `TypeError: from.getTime is not a function`. It must revive them instead.
it("renders a defaultValue whose dates were serialized to strings", () => {
const onSelect = vi.fn()
const serializedDefaultValue = {
value: {
from: new Date(2023, 0, 1).toISOString(),
to: new Date(2023, 0, 31).toISOString(),
},
granularity: "month" as const,
} as unknown as Parameters<typeof OneDateNavigator>[0]["defaultValue"]

expect(() =>
render(
<OneDateNavigator
onSelect={onSelect}
defaultValue={serializedDefaultValue}
/>
)
).not.toThrow()

expect(screen.getByRole("button", { name: "January 2023" })).toBeDefined()
})

it("renders a controlled value whose dates were serialized to strings", () => {
const onSelect = vi.fn()
const serializedValue = {
value: {
from: new Date(2023, 0, 1).toISOString(),
to: new Date(2023, 0, 31).toISOString(),
},
granularity: "month" as const,
} as unknown as Parameters<typeof OneDateNavigator>[0]["value"]

expect(() =>
render(<OneDateNavigator onSelect={onSelect} value={serializedValue} />)
).not.toThrow()

expect(screen.getByRole("button", { name: "January 2023" })).toBeDefined()
})

it("renders with go to current hidden", () => {
const onSelect = vi.fn()

Expand Down
37 changes: 37 additions & 0 deletions packages/react/src/ui/DatePickerPopup/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
import { DatePickerValue } from "./types"

const coerceToDate = (value: Date | string | number): Date =>
value instanceof Date ? value : new Date(value)

/**
* Revives a `DatePickerValue` whose `from`/`to` may have been serialized to
* strings before reaching us.
*
* The date range is typed as `Date` objects, but a value that has round-tripped
* through `JSON.stringify`/`JSON.parse` — e.g. a `OneDataCollection`
* `date-navigator` filter restored from persisted storage — arrives with
* `from`/`to` as ISO strings. Downstream consumers (the equality check, the
* trigger label, granularity math) all call `Date` methods such as
* `.getTime()`, so a string `from` throws
* `TypeError: from.getTime is not a function`.
*
* Returns the original reference untouched when the range is already made of
* real `Date` objects (the common case), so this adds no re-render churn.
*/
export const reviveDatePickerValue = (
value: DatePickerValue | undefined
): DatePickerValue | undefined => {
if (!value?.value) {
return value
}
const { from, to } = value.value as {
from: Date | string | number
to: Date | string | number
}
if (from instanceof Date && to instanceof Date) {
return value
}
return {
...value,
value: { from: coerceToDate(from), to: coerceToDate(to) },
}
}

export const isSameDatePickerValue = (
a: DatePickerValue | undefined,
b: DatePickerValue | undefined
Expand Down
Loading