From 8cf8e1eba2e86b909d997dd747ad9e924a3f8942 Mon Sep 17 00:00:00 2001 From: Lebaud Antoine Date: Wed, 12 Jul 2023 22:12:31 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9(react)=20harmonize=20date=20picker?= =?UTF-8?q?=20components=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/swift-forks-exercise.md | 5 +++ .../Forms/DatePicker/DatePicker.spec.tsx | 24 +++++++---- .../Forms/DatePicker/DatePicker.tsx | 3 +- .../Forms/DatePicker/DatePickerAux.tsx | 7 ++-- .../Forms/DatePicker/DateRangePicker.spec.tsx | 24 ++++++++--- .../Forms/DatePicker/DateRangePicker.tsx | 6 ++- .../components/Forms/DatePicker/utils.spec.ts | 42 ++++++++++++++++++- .../src/components/Forms/DatePicker/utils.ts | 17 ++++++++ 8 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .changeset/swift-forks-exercise.md diff --git a/.changeset/swift-forks-exercise.md b/.changeset/swift-forks-exercise.md new file mode 100644 index 0000000..af9c811 --- /dev/null +++ b/.changeset/swift-forks-exercise.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": patch +--- + +Fix datepicker component's output timezone management diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx index 8c5485f..60b9fa2 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx @@ -1,16 +1,24 @@ import userEvent from "@testing-library/user-event"; import { render, screen, within } from "@testing-library/react"; import React, { FormEvent, useState } from "react"; -import { afterEach, expect, vi } from "vitest"; +import { expect, vi } from "vitest"; import { CunninghamProvider } from ":/components/Provider"; import { DatePicker } from ":/components/Forms/DatePicker/DatePicker"; import { Button } from ":/components/Button"; -describe("", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@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("", () => { const expectCalendarToBeClosed = () => { expect(screen.queryByRole("application")).toBeNull(); }; @@ -525,7 +533,7 @@ describe("", () => { expectCalendarToBeClosed(); // Make sure value is selected. - screen.getByText(`Value = 2023-04-12|`); + screen.getByText(`Value = 2023-04-11T22:00:00.000Z|`); // Clear value. const clearButton = screen.getByRole("button", { @@ -633,7 +641,7 @@ describe("", () => { expect(monthSegment).toHaveFocus(); // 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. await user.click(submitButton); @@ -642,7 +650,7 @@ describe("", () => { // Make sure form's value matches. expect(formData).toEqual({ - datepicker: "2023-12-05", + datepicker: "2023-05-11T22:00:00.000Z", }); // Clear picked date. diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx index d5a01be..2366f84 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx @@ -12,6 +12,7 @@ import { Calendar } from ":/components/Forms/DatePicker/Calendar"; import DateFieldBox from ":/components/Forms/DatePicker/DateField"; import { StringOrDate } from ":/components/Forms/DatePicker/types"; import { + convertDateValueToString, getDefaultPickerOptions, parseCalendarDate, } from ":/components/Forms/DatePicker/utils"; @@ -41,7 +42,7 @@ export const DatePicker = (props: DatePickerProps) => { : parseCalendarDate(props.value), defaultValue: parseCalendarDate(props.defaultValue), onChange: (value: DateValue | null) => { - props.onChange?.(value?.toString() || ""); + props.onChange?.(convertDateValueToString(value)); }, }; const pickerState = useDatePickerState(options); diff --git a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx index ecda078..832a1d8 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx @@ -21,6 +21,7 @@ import { Calendar, CalendarRange, } from ":/components/Forms/DatePicker/Calendar"; +import { convertDateValueToString } from ":/components/Forms/DatePicker/utils"; export type DatePickerAuxSubProps = FieldProps & { label?: string; @@ -111,19 +112,19 @@ const DatePickerAux = forwardRef( ) : ( )}
diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx index 9020993..dae4cb7 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -6,6 +6,18 @@ import { CunninghamProvider } from ":/components/Provider"; import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker"; import { Button } from ":/components/Button"; +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@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("", () => { const expectDatesToBeEqual = ( firstDate: Date | string | undefined | null, @@ -938,7 +950,9 @@ describe("", () => { expectCalendarToBeClosed(); // 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. const clearButton = screen.getByRole("button", { @@ -1004,10 +1018,10 @@ describe("", () => { expect(startMonthSegment).toHaveFocus(); // 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. - 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. await user.click(submitButton); @@ -1016,8 +1030,8 @@ describe("", () => { // Make sure form's value matches. expect(formData).toEqual({ - datepickerStart: "2023-10-05", - datepickerEnd: "2023-12-05", + datepickerStart: "2023-05-09T22:00:00.000Z", + datepickerEnd: "2023-05-11T22:00:00.000Z", }); // Clear picked date. diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx index 3f762e6..fbd8275 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -12,6 +12,7 @@ import DatePickerAux, { import DateFieldBox from ":/components/Forms/DatePicker/DateField"; import { StringsOrDateRange } from ":/components/Forms/DatePicker/types"; import { + convertDateValueToString, getDefaultPickerOptions, parseRangeCalendarDate, } from ":/components/Forms/DatePicker/utils"; @@ -44,7 +45,10 @@ export const DateRangePicker = ({ onChange: (value: DateRange) => { props.onChange?.( value?.start && value.end - ? [value.start.toString(), value.end.toString()] + ? [ + convertDateValueToString(value.start), + convertDateValueToString(value.end), + ] : null, ); }, diff --git a/packages/react/src/components/Forms/DatePicker/utils.spec.ts b/packages/react/src/components/Forms/DatePicker/utils.spec.ts index 6b3ca1c..cb86013 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.spec.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.spec.ts @@ -1,5 +1,12 @@ -import { CalendarDate, DateValue, parseDate } from "@internationalized/date"; import { + CalendarDate, + DateValue, + parseAbsolute, + parseDate, +} from "@internationalized/date"; +import { vi } from "vitest"; +import { + convertDateValueToString, parseCalendarDate, parseRangeCalendarDate, } from ":/components/Forms/DatePicker/utils"; @@ -17,6 +24,18 @@ const expectDateToBeEqual = ( expect(parsedDate?.day === expectedDay); }; +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@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", () => { it.each([ [2023, 4, 12], @@ -161,3 +180,24 @@ describe("parseRangeCalendarDate", () => { 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"); + }); +}); diff --git a/packages/react/src/components/Forms/DatePicker/utils.ts b/packages/react/src/components/Forms/DatePicker/utils.ts index 731901d..3cd2bc1 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.ts @@ -1,7 +1,11 @@ import { CalendarDate, + DateValue, parseAbsoluteToLocal, toCalendarDate, + ZonedDateTime, + toZoned, + getLocalTimeZone, } from "@internationalized/date"; import { DateRange } from "react-aria"; 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 => ({ minValue: parseCalendarDate(props.minValue), maxValue: parseCalendarDate(props.maxValue),