🩹(react) harmonize date picker components output
By introducing this utility function, we ensure that any output value is converted back to an ISO 8601 date and time string in UTC timezone. Overall, it harmonize and factorize the way we output values from date picker components.
This commit is contained in:
committed by
aleb_the_flash
parent
bae7392fe1
commit
8cf8e1eba2
5
.changeset/swift-forks-exercise.md
Normal file
5
.changeset/swift-forks-exercise.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix datepicker component's output timezone management
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import React, { FormEvent, useState } from "react";
|
import React, { FormEvent, useState } from "react";
|
||||||
import { afterEach, expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
import { CunninghamProvider } from ":/components/Provider";
|
import { CunninghamProvider } from ":/components/Provider";
|
||||||
import { DatePicker } from ":/components/Forms/DatePicker/DatePicker";
|
import { DatePicker } from ":/components/Forms/DatePicker/DatePicker";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
|
|
||||||
describe("<DatePicker/>", () => {
|
vi.mock("@internationalized/date", async () => {
|
||||||
afterEach(() => {
|
const mod = await vi.importActual<typeof import("@internationalized/date")>(
|
||||||
vi.restoreAllMocks();
|
"@internationalized/date"
|
||||||
});
|
);
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
// Note: Restoring mocks will cause the function to return 'undefined'.
|
||||||
|
// Consider providing a default implementation to be restored instead.
|
||||||
|
getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("<DatePicker/>", () => {
|
||||||
const expectCalendarToBeClosed = () => {
|
const expectCalendarToBeClosed = () => {
|
||||||
expect(screen.queryByRole("application")).toBeNull();
|
expect(screen.queryByRole("application")).toBeNull();
|
||||||
};
|
};
|
||||||
@@ -525,7 +533,7 @@ describe("<DatePicker/>", () => {
|
|||||||
expectCalendarToBeClosed();
|
expectCalendarToBeClosed();
|
||||||
|
|
||||||
// Make sure value is selected.
|
// Make sure value is selected.
|
||||||
screen.getByText(`Value = 2023-04-12|`);
|
screen.getByText(`Value = 2023-04-11T22:00:00.000Z|`);
|
||||||
|
|
||||||
// Clear value.
|
// Clear value.
|
||||||
const clearButton = screen.getByRole("button", {
|
const clearButton = screen.getByRole("button", {
|
||||||
@@ -633,7 +641,7 @@ describe("<DatePicker/>", () => {
|
|||||||
expect(monthSegment).toHaveFocus();
|
expect(monthSegment).toHaveFocus();
|
||||||
|
|
||||||
// Type date's value.
|
// Type date's value.
|
||||||
await user.keyboard("{1}{2}{5}{2}{0}{2}{3}");
|
await user.keyboard("{5}{1}{2}{2}{0}{2}{3}");
|
||||||
|
|
||||||
// Submit form being filled with a date.
|
// Submit form being filled with a date.
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
@@ -642,7 +650,7 @@ describe("<DatePicker/>", () => {
|
|||||||
|
|
||||||
// Make sure form's value matches.
|
// Make sure form's value matches.
|
||||||
expect(formData).toEqual({
|
expect(formData).toEqual({
|
||||||
datepicker: "2023-12-05",
|
datepicker: "2023-05-11T22:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear picked date.
|
// Clear picked date.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Calendar } from ":/components/Forms/DatePicker/Calendar";
|
|||||||
import DateFieldBox from ":/components/Forms/DatePicker/DateField";
|
import DateFieldBox from ":/components/Forms/DatePicker/DateField";
|
||||||
import { StringOrDate } from ":/components/Forms/DatePicker/types";
|
import { StringOrDate } from ":/components/Forms/DatePicker/types";
|
||||||
import {
|
import {
|
||||||
|
convertDateValueToString,
|
||||||
getDefaultPickerOptions,
|
getDefaultPickerOptions,
|
||||||
parseCalendarDate,
|
parseCalendarDate,
|
||||||
} from ":/components/Forms/DatePicker/utils";
|
} from ":/components/Forms/DatePicker/utils";
|
||||||
@@ -41,7 +42,7 @@ export const DatePicker = (props: DatePickerProps) => {
|
|||||||
: parseCalendarDate(props.value),
|
: parseCalendarDate(props.value),
|
||||||
defaultValue: parseCalendarDate(props.defaultValue),
|
defaultValue: parseCalendarDate(props.defaultValue),
|
||||||
onChange: (value: DateValue | null) => {
|
onChange: (value: DateValue | null) => {
|
||||||
props.onChange?.(value?.toString() || "");
|
props.onChange?.(convertDateValueToString(value));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const pickerState = useDatePickerState(options);
|
const pickerState = useDatePickerState(options);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
} from ":/components/Forms/DatePicker/Calendar";
|
} from ":/components/Forms/DatePicker/Calendar";
|
||||||
|
import { convertDateValueToString } from ":/components/Forms/DatePicker/utils";
|
||||||
|
|
||||||
export type DatePickerAuxSubProps = FieldProps & {
|
export type DatePickerAuxSubProps = FieldProps & {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -111,19 +112,19 @@ const DatePickerAux = forwardRef(
|
|||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name={name && `${name}_start`}
|
name={name && `${name}_start`}
|
||||||
value={pickerState.value?.start?.toString() || ""}
|
value={convertDateValueToString(pickerState.value.start)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name={name && `${name}_end`}
|
name={name && `${name}_end`}
|
||||||
value={pickerState.value?.end?.toString() || ""}
|
value={convertDateValueToString(pickerState.value.end)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name={name}
|
name={name}
|
||||||
value={pickerState.value?.toString() || ""}
|
value={convertDateValueToString(pickerState.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="c__date-picker__wrapper__icon">
|
<div className="c__date-picker__wrapper__icon">
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import { CunninghamProvider } from ":/components/Provider";
|
|||||||
import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker";
|
import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
|
|
||||||
|
vi.mock("@internationalized/date", async () => {
|
||||||
|
const mod = await vi.importActual<typeof import("@internationalized/date")>(
|
||||||
|
"@internationalized/date"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
// Note: Restoring mocks will cause the function to return 'undefined'.
|
||||||
|
// Consider providing a default implementation to be restored instead.
|
||||||
|
getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("<DateRangePicker/>", () => {
|
describe("<DateRangePicker/>", () => {
|
||||||
const expectDatesToBeEqual = (
|
const expectDatesToBeEqual = (
|
||||||
firstDate: Date | string | undefined | null,
|
firstDate: Date | string | undefined | null,
|
||||||
@@ -938,7 +950,9 @@ describe("<DateRangePicker/>", () => {
|
|||||||
expectCalendarToBeClosed();
|
expectCalendarToBeClosed();
|
||||||
|
|
||||||
// Make sure value is selected.
|
// Make sure value is selected.
|
||||||
screen.getByText(`Value = 2023-04-12 2023-04-14|`);
|
screen.getByText(
|
||||||
|
`Value = 2023-04-11T22:00:00.000Z 2023-04-13T22:00:00.000Z|`
|
||||||
|
);
|
||||||
|
|
||||||
// Clear value.
|
// Clear value.
|
||||||
const clearButton = screen.getByRole("button", {
|
const clearButton = screen.getByRole("button", {
|
||||||
@@ -1004,10 +1018,10 @@ describe("<DateRangePicker/>", () => {
|
|||||||
expect(startMonthSegment).toHaveFocus();
|
expect(startMonthSegment).toHaveFocus();
|
||||||
|
|
||||||
// Type start date's value.
|
// Type start date's value.
|
||||||
await user.keyboard("{1}{0}{5}{2}{0}{2}{3}");
|
await user.keyboard("{5}{1}{0}{2}{0}{2}{3}");
|
||||||
|
|
||||||
// Type end date's value.
|
// Type end date's value.
|
||||||
await user.keyboard("{1}{2}{5}{2}{0}{2}{3}");
|
await user.keyboard("{5}{1}{2}{2}{0}{2}{3}");
|
||||||
|
|
||||||
// Submit form being filled with a date.
|
// Submit form being filled with a date.
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
@@ -1016,8 +1030,8 @@ describe("<DateRangePicker/>", () => {
|
|||||||
|
|
||||||
// Make sure form's value matches.
|
// Make sure form's value matches.
|
||||||
expect(formData).toEqual({
|
expect(formData).toEqual({
|
||||||
datepickerStart: "2023-10-05",
|
datepickerStart: "2023-05-09T22:00:00.000Z",
|
||||||
datepickerEnd: "2023-12-05",
|
datepickerEnd: "2023-05-11T22:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear picked date.
|
// Clear picked date.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import DatePickerAux, {
|
|||||||
import DateFieldBox from ":/components/Forms/DatePicker/DateField";
|
import DateFieldBox from ":/components/Forms/DatePicker/DateField";
|
||||||
import { StringsOrDateRange } from ":/components/Forms/DatePicker/types";
|
import { StringsOrDateRange } from ":/components/Forms/DatePicker/types";
|
||||||
import {
|
import {
|
||||||
|
convertDateValueToString,
|
||||||
getDefaultPickerOptions,
|
getDefaultPickerOptions,
|
||||||
parseRangeCalendarDate,
|
parseRangeCalendarDate,
|
||||||
} from ":/components/Forms/DatePicker/utils";
|
} from ":/components/Forms/DatePicker/utils";
|
||||||
@@ -44,7 +45,10 @@ export const DateRangePicker = ({
|
|||||||
onChange: (value: DateRange) => {
|
onChange: (value: DateRange) => {
|
||||||
props.onChange?.(
|
props.onChange?.(
|
||||||
value?.start && value.end
|
value?.start && value.end
|
||||||
? [value.start.toString(), value.end.toString()]
|
? [
|
||||||
|
convertDateValueToString(value.start),
|
||||||
|
convertDateValueToString(value.end),
|
||||||
|
]
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { CalendarDate, DateValue, parseDate } from "@internationalized/date";
|
|
||||||
import {
|
import {
|
||||||
|
CalendarDate,
|
||||||
|
DateValue,
|
||||||
|
parseAbsolute,
|
||||||
|
parseDate,
|
||||||
|
} from "@internationalized/date";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import {
|
||||||
|
convertDateValueToString,
|
||||||
parseCalendarDate,
|
parseCalendarDate,
|
||||||
parseRangeCalendarDate,
|
parseRangeCalendarDate,
|
||||||
} from ":/components/Forms/DatePicker/utils";
|
} from ":/components/Forms/DatePicker/utils";
|
||||||
@@ -17,6 +24,18 @@ const expectDateToBeEqual = (
|
|||||||
expect(parsedDate?.day === expectedDay);
|
expect(parsedDate?.day === expectedDay);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vi.mock("@internationalized/date", async () => {
|
||||||
|
const mod = await vi.importActual<typeof import("@internationalized/date")>(
|
||||||
|
"@internationalized/date",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
// Note: Restoring mocks will cause the function to return 'undefined'.
|
||||||
|
// Consider providing a default implementation to be restored instead.
|
||||||
|
getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseCalendarDate", () => {
|
describe("parseCalendarDate", () => {
|
||||||
it.each([
|
it.each([
|
||||||
[2023, 4, 12],
|
[2023, 4, 12],
|
||||||
@@ -161,3 +180,24 @@ describe("parseRangeCalendarDate", () => {
|
|||||||
expectDateToBeEqual(range?.end, 2023, 4, 22);
|
expectDateToBeEqual(range?.end, 2023, 4, 22);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("convertDateValueToString", () => {
|
||||||
|
it("should return an empty string for null date", () => {
|
||||||
|
const date: DateValue | null = null;
|
||||||
|
const result = convertDateValueToString(date);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a UTC ISO 8601 string from a CalendarDate", async () => {
|
||||||
|
const date = parseDate("2023-05-25");
|
||||||
|
const result = convertDateValueToString(date);
|
||||||
|
expect(result).eq("2023-05-24T22:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a UTC ISO 8601 string from a ZonedDateTime", async () => {
|
||||||
|
// Europe/Paris is the mocked local timezone.
|
||||||
|
const date = parseAbsolute("2023-05-25T00:00:00.000+02:00", "Europe/Paris");
|
||||||
|
const result = convertDateValueToString(date);
|
||||||
|
expect(result).eq("2023-05-24T22:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
CalendarDate,
|
CalendarDate,
|
||||||
|
DateValue,
|
||||||
parseAbsoluteToLocal,
|
parseAbsoluteToLocal,
|
||||||
toCalendarDate,
|
toCalendarDate,
|
||||||
|
ZonedDateTime,
|
||||||
|
toZoned,
|
||||||
|
getLocalTimeZone,
|
||||||
} from "@internationalized/date";
|
} from "@internationalized/date";
|
||||||
import { DateRange } from "react-aria";
|
import { DateRange } from "react-aria";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +42,19 @@ export const parseRangeCalendarDate = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertDateValueToString = (date: DateValue | null): string => {
|
||||||
|
try {
|
||||||
|
const localTimezone = getLocalTimeZone();
|
||||||
|
// If timezone is already set, it would be kept, else the selection is set at midnight
|
||||||
|
// on the local timezone, then converted to a UTC offset.
|
||||||
|
return date ? toZoned(date, localTimezone).toAbsoluteString() : "";
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid date format when converting date value on DatePicker component"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({
|
export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({
|
||||||
minValue: parseCalendarDate(props.minValue),
|
minValue: parseCalendarDate(props.minValue),
|
||||||
maxValue: parseCalendarDate(props.maxValue),
|
maxValue: parseCalendarDate(props.maxValue),
|
||||||
|
|||||||
Reference in New Issue
Block a user