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),