From cd42afb10e84b5deed0fec2b6917b5ca1847bdfe Mon Sep 17 00:00:00 2001 From: Lebaud Antoine Date: Mon, 24 Jul 2023 22:02:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20a=20timezone=20props?= =?UTF-8?q?=20on=20date=20picker=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default, component's timezone is the user locale timezone. Component now offers a way to set its timezone to any supported Intl timezone format. Please note that output values from the component will always be converted to a UTC timezone. --- .changeset/curly-pants-remain.md | 5 ++ .../Forms/DatePicker/DatePicker.spec.tsx | 48 +++++++++++++- .../Forms/DatePicker/DatePicker.tsx | 6 +- .../Forms/DatePicker/DatePickerAux.tsx | 16 ++++- .../Forms/DatePicker/DateRangePicker.spec.tsx | 48 +++++++++++++- .../Forms/DatePicker/DateRangePicker.tsx | 11 ++-- .../components/Forms/DatePicker/utils.spec.ts | 58 ++++++++++++++++- .../src/components/Forms/DatePicker/utils.ts | 62 ++++++++++++++----- 8 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 .changeset/curly-pants-remain.md diff --git a/.changeset/curly-pants-remain.md b/.changeset/curly-pants-remain.md new file mode 100644 index 0000000..2a6b4c5 --- /dev/null +++ b/.changeset/curly-pants-remain.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +Add a timezone props to date picker components diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx index b3f9d69..cc1ce3e 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx @@ -401,9 +401,7 @@ describe("", () => { /> , ), - ).toThrow( - "Invalid date format when initializing props on DatePicker component", - ); + ).toThrow(/Failed to parse date value:/); }, ); @@ -542,6 +540,50 @@ describe("", () => { screen.getByText("Value = |"); }); + it("has a timezone", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState(null); + return ( + +
+
Value = {value}|
+ + setValue(e)} + timezone="America/Sao_Paulo" + /> +
+
+ ); + }; + render(); + + // Make sure any value is selected. + screen.getByText("Value = |"); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const monthSegment = await screen.getByRole("spinbutton", { + name: /month/, + }); + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Make sure value is selected at midnight on America/Sao_Paulo. + screen.getByText(`Value = 2023-05-12T03:00:00.000Z|`); + }); + it("renders disabled", async () => { render( diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx index 365f8e5..8264f21 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx @@ -38,10 +38,10 @@ export const DatePicker = (props: DatePickerProps) => { // Force clear the component's value when passing null or an empty string. props.value === "" || props.value === null ? null - : parseDateValue(props.value), - defaultValue: parseDateValue(props.defaultValue), + : parseDateValue(props.value, props.timezone), + defaultValue: parseDateValue(props.defaultValue, props.timezone), onChange: (value: DateValue | null) => { - props.onChange?.(convertDateValueToString(value)); + props.onChange?.(convertDateValueToString(value, props.timezone)); }, }; 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 23f1b5c..b06f291 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx @@ -29,6 +29,7 @@ export type DatePickerAuxSubProps = FieldProps & { disabled?: boolean; name?: string; locale?: string; + timezone?: string; }; export type DatePickerAuxProps = PropsWithChildren & @@ -111,19 +112,28 @@ 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 69a22e0..a860d1b 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -698,9 +698,7 @@ describe("", () => { /> , ), - ).toThrow( - "Invalid date format when initializing props on DatePicker component", - ); + ).toThrow(/Failed to parse date value:/); }); it("has not a valid range value", async () => { @@ -991,6 +989,50 @@ describe("", () => { screen.getByText("Value = |"); }); + it("has a timezone", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState<[string, string] | null>(null); + return ( + +
+
Value = {value?.join(" ")}|
+ + setValue(e)} + timezone="America/Sao_Paulo" + /> +
+
+ ); + }; + render(); + + // Make sure any value is selected. + screen.getByText("Value = |"); + + const allSegments = await screen.getAllByRole("spinbutton"); + const startMonthSegment = allSegments![0]; + + // Select the first segment, month one. + await user.click(startMonthSegment); + expect(startMonthSegment).toHaveFocus(); + + // Type start date's value. + await user.keyboard("{5}{1}{0}{2}{0}{2}{3}"); + + // Type end date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Make sure values is selected at midnight on America/Sao_Paulo. + screen.getByText( + `Value = 2023-05-10T03:00:00.000Z 2023-05-12T03:00:00.000Z|`, + ); + }); + it("submits forms data", async () => { let formData: any; const Wrapper = () => { diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx index 1d02b86..21544e3 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -40,14 +40,17 @@ export const DateRangePicker = ({ const options: DateRangePickerStateOptions = { ...getDefaultPickerOptions(props), - value: props.value === null ? null : parseRangeDateValue(props.value), - defaultValue: parseRangeDateValue(props.defaultValue), + value: + props.value === null + ? null + : parseRangeDateValue(props.value, props.timezone), + defaultValue: parseRangeDateValue(props.defaultValue, props.timezone), onChange: (value: DateRange) => { props.onChange?.( value?.start && value.end ? [ - convertDateValueToString(value.start), - convertDateValueToString(value.end), + convertDateValueToString(value.start, props.timezone), + convertDateValueToString(value.end, props.timezone), ] : null, ); diff --git a/packages/react/src/components/Forms/DatePicker/utils.spec.ts b/packages/react/src/components/Forms/DatePicker/utils.spec.ts index 9fc01dd..d581433 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.spec.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.spec.ts @@ -2,6 +2,7 @@ import { DateValue, parseAbsolute, parseDate } from "@internationalized/date"; import { vi } from "vitest"; import { convertDateValueToString, + isValidTimeZone, parseDateValue, parseRangeDateValue, } from ":/components/Forms/DatePicker/utils"; @@ -43,6 +44,15 @@ describe("parseDateValue", () => { expect(parsedDate?.hour).eq(0); }); + it("should parse time to the right timezone", async () => { + const parsedDate = parseDateValue( + "2023-05-11T00:00:00.000Z", + "America/Sao_Paulo", + ); + expectDateToBeEqual(parsedDate, 2023, 5, 10); + expect(parsedDate?.hour).eq(21); + }); + it.each([undefined, ""])("parse an empty or null date", (date) => { const parsedDate = parseDateValue(date); expect(parsedDate).eq(undefined); @@ -59,9 +69,15 @@ describe("parseDateValue", () => { "2022-04-01T00:00:00.000", ])("parse a wrong date", (wrongFormattedDate) => { expect(() => parseDateValue(wrongFormattedDate)).toThrow( - "Invalid date format when initializing props on DatePicker component", + /Failed to parse date value:/, ); }); + + it("should raise an error when timezone is invalid", async () => { + expect(() => + parseDateValue("2023-05-11T00:00:00.000Z", "Invalid/Timezone"), + ).toThrow(/Failed to parse date value:/); + }); }); describe("parseRangeDateValue", () => { @@ -119,4 +135,44 @@ describe("convertDateValueToString", () => { const result = convertDateValueToString(date); expect(result).eq("2023-05-24T22:00:00.000Z"); }); + + it("should convert time to the right timezone", async () => { + const date = parseDate("2023-05-25"); + const result = convertDateValueToString(date, "America/Sao_Paulo"); + expect(result).eq("2023-05-25T03:00:00.000Z"); + }); + + it("should raise an error when timezone is invalid", async () => { + const date = parseDate("2023-05-25"); + expect(() => convertDateValueToString(date, "Invalid/Timezone")).toThrow( + /Failed to convert date value to string:/, + ); + }); +}); + +describe("isValidTimeZone", () => { + it.each(["UTC", "Europe/Paris", "America/Sao_Paulo"])( + "should return true when timezone is valid", + (timezone) => { + const isValid = isValidTimeZone(timezone); + expect(isValid).toBe(true); + }, + ); + + it("should return false when timezone is invalid", () => { + const isNotValid = isValidTimeZone("Invalid/Timezone"); + expect(isNotValid).toBe(false); + }); + + it("should return false when Intl or time zones are not available", () => { + // Mock Intl to simulate the absence of Intl or time zones support + const originalDateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("Time zones are not available"); + }); + const result = isValidTimeZone("Europe/Paris"); + expect(result).toBe(false); + // Restore the original implementation after the test + (Intl as any).DateTimeFormat = originalDateTimeFormat; + }); }); diff --git a/packages/react/src/components/Forms/DatePicker/utils.ts b/packages/react/src/components/Forms/DatePicker/utils.ts index 5099aaf..d2f906f 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.ts @@ -4,53 +4,83 @@ import { ZonedDateTime, toZoned, getLocalTimeZone, + parseAbsolute, } from "@internationalized/date"; import { DateRange } from "react-aria"; import { DatePickerAuxSubProps } from ":/components/Forms/DatePicker/DatePickerAux"; +export const isValidTimeZone = (timezone: string) => { + try { + // Check if Intl is available and supports time zones + if (!Intl || !Intl.DateTimeFormat().resolvedOptions().timeZone) { + throw new Error("Time zones are not available in this environment"); + } + + // Test if the provided time zone is valid + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + + return true; + } catch (error) { + // If an error occurs, it could be due to an invalid time zone or lack of Intl support + return false; + } +}; + export const parseDateValue = ( rawDate: string | undefined, + timezone?: string, ): undefined | ZonedDateTime => { if (!rawDate) { return undefined; } try { - return parseAbsoluteToLocal(rawDate); + if (timezone && !isValidTimeZone(timezone)) { + throw new Error("Invalid timezone provided."); + } + return timezone + ? parseAbsolute(rawDate, timezone) + : parseAbsoluteToLocal(rawDate); } catch (e) { - throw new Error( - "Invalid date format when initializing props on DatePicker component", - ); + const errorMessage = e instanceof Error ? ": " + e.message : "."; + throw new Error("Failed to parse date value" + errorMessage); } }; export const parseRangeDateValue = ( rawRange: [string, string] | undefined, + timezone?: string, ): DateRange | undefined => { if (!rawRange || !rawRange[0] || !rawRange[1]) { return undefined; } return { - start: parseDateValue(rawRange[0])!, - end: parseDateValue(rawRange[1])!, + start: parseDateValue(rawRange[0], timezone)!, + end: parseDateValue(rawRange[1], timezone)!, }; }; -export const convertDateValueToString = (date: DateValue | null): string => { +export const convertDateValueToString = ( + date: DateValue | null, + timezone?: string, +): 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() : ""; + if (!date) { + return ""; + } + const localTimezone = timezone || getLocalTimeZone(); + if (!isValidTimeZone(localTimezone)) { + throw new Error("Invalid timezone provided."); + } + return toZoned(date, localTimezone).toAbsoluteString(); } catch (e) { - throw new Error( - "Invalid date format when converting date value on DatePicker component", - ); + const errorMessage = e instanceof Error ? ": " + e.message : "."; + throw new Error("Failed to convert date value to string" + errorMessage); } }; export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({ - minValue: parseDateValue(props.minValue), - maxValue: parseDateValue(props.maxValue), + minValue: parseDateValue(props.minValue, props.timezone), + maxValue: parseDateValue(props.maxValue, props.timezone), shouldCloseOnSelect: true, granularity: "day", isDisabled: props.disabled,