From 0dc46d1144282eb757d004a9434bf831a289be07 Mon Sep 17 00:00:00 2001 From: Lebaud Antoine Date: Wed, 12 Jul 2023 22:51:32 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20restrict=20inputs=20formats?= =?UTF-8?q?=20in=20date=20picker=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces a standardized approach for end consumers using the component's API. Consumers are now required to provide a date as an ISO string in either UTC or with a UTC offset. The support for native Date objects has been removed to ensure consistent and reliable behavior across all implementations. By adopting this uniform input format, we can simplify usage and avoid potential inconsistencies in date handling. --- .changeset/smart-tables-kiss.md | 5 + .../Forms/DatePicker/DatePicker.spec.tsx | 277 +++++++++++++----- .../Forms/DatePicker/DatePicker.tsx | 11 +- .../Forms/DatePicker/DatePickerAux.tsx | 5 +- .../Forms/DatePicker/DateRangePicker.spec.tsx | 184 +++++++----- .../Forms/DatePicker/DateRangePicker.tsx | 14 +- .../Forms/DatePicker/index.stories.tsx | 52 ++-- .../src/components/Forms/DatePicker/index.tsx | 1 - .../src/components/Forms/DatePicker/types.ts | 2 - .../components/Forms/DatePicker/utils.spec.ts | 147 +++------- .../src/components/Forms/DatePicker/utils.ts | 29 +- 11 files changed, 403 insertions(+), 324 deletions(-) create mode 100644 .changeset/smart-tables-kiss.md delete mode 100644 packages/react/src/components/Forms/DatePicker/types.ts diff --git a/.changeset/smart-tables-kiss.md b/.changeset/smart-tables-kiss.md new file mode 100644 index 0000000..3591f3c --- /dev/null +++ b/.changeset/smart-tables-kiss.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +Restrict input formats of date picker components to IS0 strings diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx index 60b9fa2..b3f9d69 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx @@ -8,7 +8,7 @@ import { Button } from ":/components/Button"; vi.mock("@internationalized/date", async () => { const mod = await vi.importActual( - "@internationalized/date" + "@internationalized/date", ); return { ...mod, @@ -193,7 +193,7 @@ describe("", () => { , ); @@ -282,7 +282,7 @@ describe("", () => { , ); @@ -317,8 +317,8 @@ describe("", () => { , ); @@ -350,12 +350,9 @@ describe("", () => { }); it.each([ - "2022-05-25", - "2023-06-01T13:50", - "2025-03-25", - "Mar 25 2025", - "25 Mar 2025", - new Date("2025-04-23"), + "2022-05-25T23:59:59.000Z", + "2023-06-01T00:00:00.000Z", + "2025-03-25T12:30:00.000Z", ])("has a default value", async (defaultValue) => { render( @@ -373,8 +370,6 @@ describe("", () => { }); it("has an uncontrolled a controlled value", async () => { - const defaultValue = new Date("2023-05-24"); - const value = new Date("2023-05-24"); vi.spyOn(console, "error").mockImplementation(() => undefined); expect(() => render( @@ -382,8 +377,8 @@ describe("", () => { , ), @@ -413,14 +408,13 @@ describe("", () => { ); it("clears date", async () => { - const defaultValue = new Date("2023-05-24"); const user = userEvent.setup(); render( , ); @@ -435,7 +429,7 @@ describe("", () => { expect(dateFieldContent).eq("mm/dd/yyyy"); const isGridCellSelected = screen - .getByRole("gridcell", { name: `${defaultValue.getDate()}` })! + .getByRole("gridcell", { name: "24" })! .getAttribute("aria-selected"); expect(isGridCellSelected).toBeNull(); @@ -453,8 +447,8 @@ describe("", () => { , ); @@ -467,8 +461,8 @@ describe("", () => { , ); @@ -482,9 +476,9 @@ describe("", () => { , ); @@ -494,7 +488,9 @@ describe("", () => { it("works controlled", async () => { const user = userEvent.setup(); const Wrapper = () => { - const [value, setValue] = useState("2023-04-25"); + const [value, setValue] = useState( + "2023-04-25T00:00:00.000Z", + ); return (
@@ -513,11 +509,11 @@ describe("", () => { render(); // Make sure value is selected. - screen.getByText("Value = 2023-04-25|"); + screen.getByText("Value = 2023-04-25T00:00:00.000Z|"); // Make sure value is initially render in the date field component. const dateFieldContent = screen.getByRole("presentation").textContent; - expectDatesToBeEqual("2023-04-25", dateFieldContent); + expectDatesToBeEqual("2023-04-25T00:00:00.000Z", dateFieldContent); // Open the calendar grid. const toggleButton = (await screen.findAllByRole("button"))![1]; @@ -525,7 +521,7 @@ describe("", () => { expectCalendarToBeOpen(); const gridCell = within( - await screen.getByRole("gridcell", { name: "12" }), + screen.getByRole("gridcell", { name: "12" }), ).getByRole("button")!; // Select a new value in the calendar grid. @@ -533,7 +529,7 @@ describe("", () => { expectCalendarToBeClosed(); // Make sure value is selected. - screen.getByText(`Value = 2023-04-11T22:00:00.000Z|`); + screen.getByText(`Value = 2023-04-12T00:00:00.000Z|`); // Clear value. const clearButton = screen.getByRole("button", { @@ -552,7 +548,7 @@ describe("", () => { , @@ -584,7 +580,7 @@ describe("", () => { , ); @@ -649,6 +645,7 @@ describe("", () => { expectDateFieldToBeDisplayed(); // Make sure form's value matches. + // It should be equal the 12th of May at midnight in local timezone. expect(formData).toEqual({ datepicker: "2023-05-11T22:00:00.000Z", }); @@ -663,7 +660,7 @@ describe("", () => { // Submit the form being empty. await user.click(submitButton); - // Date field disappears when the user click outside the comppnent. + // Date field disappears when the user click outside the component. expectDateFieldToBeHidden(); // Make sure form's value is null. @@ -672,15 +669,118 @@ describe("", () => { }); }); + it("submits forms data with a default value", async () => { + let formData: any; + const Wrapper = () => { + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + formData = { + datepicker: data.get("datepicker"), + }; + }; + + return ( + +
+
+ + + +
+
+ ); + }; + render(); + + const user = userEvent.setup(); + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + + // Submit the form with the default value. + // Time should be equal to the initial one in UTC. + await user.click(submitButton); + expect(formData).toEqual({ + datepicker: "2023-04-25T12:00:00.000Z", + }); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + 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}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Make sure form's value matches. + // Selection should keep the default time passed to the component. + // Thus, component output should still be at noon UTC. + expect(formData).toEqual({ + datepicker: "2023-05-12T12:00:00.000Z", + }); + + // Clear picked date. + // We lose info about the initial time. + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectDateFieldToBeDisplayed(); + + // Submit the form being empty. + await user.click(submitButton); + + // Date field disappears when the user click outside the component. + expectDateFieldToBeHidden(); + + // Make sure form's value is null. + expect(formData).toEqual({ + datepicker: "", + }); + + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type a new date's value, that would be selected at midnight local timezone. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Make sure form's value matches. + // It should be equal the 2023-05-12 at midnight in local timezone. + expect(formData).toEqual({ + datepicker: "2023-05-11T22:00:00.000Z", + }); + }); + it("clicks next and previous focused month", async () => { - const defaultValue = new Date("2023-05-24"); const user = userEvent.setup(); render( , ); @@ -711,14 +811,13 @@ describe("", () => { }); it("clicks next and previous focused year", async () => { - const defaultValue = new Date("2023-05-24"); const user = userEvent.setup(); render( , ); @@ -749,18 +848,15 @@ describe("", () => { }); it("renders disabled next and previous month", async () => { - const defaultValue = new Date("2023-05-24"); - const minValue = new Date("2023-05-22"); - const maxValue = new Date("2023-05-26"); const user = userEvent.setup(); render( , ); @@ -782,18 +878,15 @@ describe("", () => { }); it("renders disabled next and previous year", async () => { - const defaultValue = new Date("2023-05-24"); - const minValue = new Date("2023-05-22"); - const maxValue = new Date("2023-05-26"); const user = userEvent.setup(); render( , ); @@ -816,16 +909,16 @@ describe("", () => { it("renders partially disabled next and previous month", async () => { const user = userEvent.setup(); - const minValue = new Date("2023-04-23"); - const maxValue = new Date("2023-06-23"); + const minValue = new Date("2023-04-23T00:00:00.000z"); + const maxValue = new Date("2023-06-23T00:00:00.000z"); render( , ); @@ -915,13 +1008,12 @@ describe("", () => { it("selects a focused month", async () => { const user = userEvent.setup(); - const defaultValue = new Date("2023-05-23"); render( , ); @@ -952,7 +1044,7 @@ describe("", () => { // Select a month option. const option: HTMLLIElement = screen.getByRole("option", { - name: "September", + name: "August", }); await user.click(option); @@ -961,18 +1053,17 @@ describe("", () => { // Make sure focused month has properly updated. focusedMonth = monthDropdown.textContent?.replace("arrow_drop_down", ""); - expect(focusedMonth).eq("Sep"); + expect(focusedMonth).eq("Aug"); }); it("selects a focused year", async () => { const user = userEvent.setup(); - const defaultValue = new Date("2023-05-23"); render( , ); @@ -1013,13 +1104,13 @@ describe("", () => { it("renders only cell within the focused month", async () => { const user = userEvent.setup(); - const defaultValue = new Date("2023-05-23"); + const defaultValue = new Date("2023-05-23T00:00:00.000Z"); render( , ); @@ -1053,13 +1144,12 @@ describe("", () => { it("navigate previous focused month with keyboard", async () => { const user = userEvent.setup(); - const defaultValue = new Date("2023-05-01"); render( , ); @@ -1086,13 +1176,12 @@ describe("", () => { it("navigate next focused month with keyboard", async () => { const user = userEvent.setup(); - const defaultValue = new Date("2023-05-31"); render( , ); @@ -1123,7 +1212,7 @@ describe("", () => { , ); @@ -1169,7 +1258,10 @@ describe("", () => { const user = userEvent.setup(); render( - + , ); @@ -1216,7 +1308,7 @@ describe("", () => { , @@ -1271,4 +1363,51 @@ describe("", () => { ), ).toThrow("Incorrect locale information provided"); }); + + it("keeps time component while selecting a date", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState( + "2023-04-25T12:00:00.000Z", + ); + return ( + +
+
Value = {value}|
+ + setValue(e)} + /> +
+
+ ); + }; + render(); + + // Make sure initial value is printed. + screen.getByText("Value = 2023-04-25T12:00:00.000Z|"); + + // Make sure value is initially rendered in the date field component. + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual("2023-04-25", dateFieldContent); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const gridCell = within( + screen.getByRole("gridcell", { name: "12" }), + ).getByRole("button")!; + + // Select a new value in the calendar grid. + await user.click(gridCell); + expectCalendarToBeClosed(); + + // Make sure value is selected, with the same time as the initial value. + screen.getByText(`Value = 2023-04-12T12:00:00.000Z|`); + }); }); diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx index 2366f84..365f8e5 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx @@ -10,17 +10,16 @@ import DatePickerAux, { } from ":/components/Forms/DatePicker/DatePickerAux"; 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, + parseDateValue, } from ":/components/Forms/DatePicker/utils"; export type DatePickerProps = DatePickerAuxSubProps & { - value?: null | StringOrDate; + value?: null | string; label: string; - defaultValue?: StringOrDate; + defaultValue?: string; onChange?: (value: string | null) => void | undefined; }; @@ -39,8 +38,8 @@ export const DatePicker = (props: DatePickerProps) => { // Force clear the component's value when passing null or an empty string. props.value === "" || props.value === null ? null - : parseCalendarDate(props.value), - defaultValue: parseCalendarDate(props.defaultValue), + : parseDateValue(props.value), + defaultValue: parseDateValue(props.defaultValue), onChange: (value: DateValue | null) => { props.onChange?.(convertDateValueToString(value)); }, diff --git a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx index 832a1d8..23f1b5c 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx @@ -16,7 +16,6 @@ import { Button } from ":/components/Button"; import { Popover } from ":/components/Popover"; import { Field, FieldProps } from ":/components/Forms/Field"; import { useCunningham } from ":/components/Provider"; -import { StringOrDate } from ":/components/Forms/DatePicker/types"; import { Calendar, CalendarRange, @@ -25,8 +24,8 @@ import { convertDateValueToString } from ":/components/Forms/DatePicker/utils"; export type DatePickerAuxSubProps = FieldProps & { label?: string; - minValue?: StringOrDate; - maxValue?: StringOrDate; + minValue?: string; + maxValue?: string; disabled?: boolean; name?: string; locale?: string; diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx index dae4cb7..69a22e0 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -8,7 +8,7 @@ import { Button } from ":/components/Button"; vi.mock("@internationalized/date", async () => { const mod = await vi.importActual( - "@internationalized/date" + "@internationalized/date", ); return { ...mod, @@ -151,9 +151,8 @@ describe("", () => { }); it.each([ - ["2022-05-25", "2022-05-26"], - ["25 Mar 2025", "2029-05-26"], - ["2023-06-01T13:50", "2024-05-26"], + ["2022-05-25T00:00:00.000Z", "2022-05-26T00:00:00.000Z"], + ["2023-06-01T00:00:00.000Z", "2024-05-26T00:00:00.000Z"], ])("has a default value", async (start: string, end: string) => { render( @@ -180,7 +179,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-04-25", "2023-05-25"]} + defaultValue={[ + "2023-04-25T00:00:00.000Z", + "2023-05-25T00:00:00.000Z", + ]} /> , ); @@ -375,7 +377,10 @@ describe("", () => { , @@ -437,7 +442,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-01-01", "2023-01-01"]} + defaultValue={[ + "2023-01-01T00:00:00.000Z", + "2023-01-01T00:00:00.000Z", + ]} /> , ); @@ -486,7 +494,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-01-01", "2023-01-01"]} + defaultValue={[ + "2023-01-01T00:00:00.000Z", + "2023-01-01T00:00:00.000Z", + ]} /> , ); @@ -657,8 +668,11 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2022-05-25", "2022-05-26"]} - value={["2022-05-25", "2022-05-26"]} + defaultValue={[ + "2022-05-25T00:00:00.000Z", + "2022-05-26T00:00:00.000Z", + ]} + value={["2022-05-25T00:00:00.000Z", "2022-05-26T00:00:00.000Z"]} /> , ), @@ -668,9 +682,9 @@ describe("", () => { }); it.each([ - ["this_not_a_valid_date", "2022-05-12"], - ["2022-05-12", "2023-13-13"], - ["2025-25-05", "2022-05-12"], + ["this_not_a_valid_date", "2022-05-12T00:00:00.000Z"], + ["2022-05-12T00:00:00.000Z", "2023-13-13"], + ["2025-25-05T00:00:00.000Z", "2022-05-12T00:00:00.000Z"], ])("has not a valid range value", async (start: string, end: string) => { vi.spyOn(console, "error").mockImplementation(() => undefined); expect(() => @@ -697,76 +711,77 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2024-05-25", "2022-05-26"]} + defaultValue={[ + "2024-05-25T00:00:00.000Z", + "2022-05-26T00:00:00.000Z", + ]} /> , ); await expectDateRangePickerStateToBe("invalid"); }); - it.each([[new Date(2022, 5, 25), new Date(2022, 5, 27)]])( - "clears date", - async (start: Date, end: Date) => { - const user = userEvent.setup(); - render( - - - , - ); + it("clears date", async () => { + const user = userEvent.setup(); + render( + + + , + ); - const clearButton = screen.getByRole("button", { - name: "Clear date", - }); - await user.click(clearButton); - expectCalendarToBeOpen(); + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectCalendarToBeOpen(); - // Date field's value should be set to a placeholder value. - const [startInput, endInput] = await screen.queryAllByRole( - "presentation", - ); - expect(startInput.textContent).eq("mm/dd/yyyy"); - expect(endInput.textContent).eq("mm/dd/yyyy"); + // Date field's value should be set to a placeholder value. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("mm/dd/yyyy"); + expect(endInput.textContent).eq("mm/dd/yyyy"); - const startGridCell = screen.getByRole("gridcell", { - name: `${start.getDate()}`, - })!; + const startGridCell = screen.getByRole("gridcell", { + name: "25", + })!; - // Make sure start grid-cell is not selected anymore. - expect(startGridCell.getAttribute("aria-selected")).toBeNull(); - expect( - startGridCell.classList.contains( - "c__calendar__wrapper__grid__week-row__background--range--start", - ), - ).toBe(false); + // Make sure start grid-cell is not selected anymore. + expect(startGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + startGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--start", + ), + ).toBe(false); - // Make sure end grid-cell is not selected anymore. - const endGridCell = screen.getByRole("gridcell", { - name: `${end.getDate()}`, - })!; - expect(endGridCell.getAttribute("aria-selected")).toBeNull(); - expect( - endGridCell.classList.contains( - "c__calendar__wrapper__grid__week-row__background--range--end", - ), - ).toBe(false); + // Make sure end grid-cell is not selected anymore. + const endGridCell = screen.getByRole("gridcell", { + name: "27", + })!; + expect(endGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + endGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--end", + ), + ).toBe(false); - // Close the calendar. - const toggleButton = (await screen.findAllByRole("button"))![1]; - await user.click(toggleButton); + // Close the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); - // Make sure the empty date field is hidden when closing the calendar. - await expectDateFieldsToBeHidden(); - }, - ); + // Make sure the empty date field is hidden when closing the calendar. + await expectDateFieldsToBeHidden(); + }); it.each([ - ["2023-01-01", "2023-01-01"], - ["2023-01-01", "2023-03-01"], + ["2023-01-01T00:00:00.000Z", "2023-01-01T00:00:00.000Z"], + ["2023-01-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], ])( "has a start or a end date inferior to minValue", async (start: string, end: string) => { @@ -777,7 +792,7 @@ describe("", () => { endLabel="End date" name="datepicker" defaultValue={[start, end]} - minValue="2023-02-01" + minValue="2023-02-01T00:00:00.000Z" /> , ); @@ -786,8 +801,8 @@ describe("", () => { ); it.each([ - ["2023-01-01", "2023-03-01"], - ["2023-03-01", "2023-03-01"], + ["2023-01-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], + ["2023-03-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], ])( "has a start or a end date superior to maxValue", async (start: string, end: string) => { @@ -798,7 +813,7 @@ describe("", () => { endLabel="End date" name="datepicker" defaultValue={[start, end]} - maxValue="2023-02-01" + maxValue="2023-02-01T00:00:00.000Z" /> , ); @@ -813,7 +828,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-01-01", "2023-01-01"]} + defaultValue={[ + "2023-01-01T00:00:00.000Z", + "2023-01-01T00:00:00.000Z", + ]} disabled={true} /> , @@ -846,7 +864,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-01-01", "2023-01-01"]} + defaultValue={[ + "2023-01-01T00:00:00.000Z", + "2023-01-01T00:00:00.000Z", + ]} /> , ); @@ -864,7 +885,10 @@ describe("", () => { startLabel="Start date" endLabel="End date" name="datepicker" - defaultValue={["2023-01-01", "2023-01-10"]} + defaultValue={[ + "2023-01-01T00:00:00.000Z", + "2023-01-10T00:00:00.000Z", + ]} /> , ); @@ -903,8 +927,8 @@ describe("", () => { const user = userEvent.setup(); const Wrapper = () => { const [value, setValue] = useState<[string, string] | null>([ - "2023-04-25", - "2023-04-26", + "2023-04-25T00:00:00.000Z", + "2023-04-26T00:00:00.000Z", ]); return ( @@ -924,7 +948,9 @@ describe("", () => { render(); // Make sure value is selected. - screen.getByText("Value = 2023-04-25 2023-04-26|"); + screen.getByText( + "Value = 2023-04-25T00:00:00.000Z 2023-04-26T00:00:00.000Z|", + ); // Make sure value is initially render in the date field component. const [startInput, endInput] = await screen.queryAllByRole("presentation"); @@ -951,7 +977,7 @@ describe("", () => { // Make sure value is selected. screen.getByText( - `Value = 2023-04-11T22:00:00.000Z 2023-04-13T22:00:00.000Z|` + `Value = 2023-04-12T00:00:00.000Z 2023-04-14T00:00:00.000Z|`, ); // Clear value. diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx index fbd8275..1d02b86 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -4,24 +4,24 @@ import { DateRangePickerStateOptions, useDateRangePickerState, } from "@react-stately/datepicker"; -import { useDateRangePicker, DateRange } from "@react-aria/datepicker"; +import { useDateRangePicker } from "@react-aria/datepicker"; +import { DateRange } from "react-aria"; import { CalendarRange } from ":/components/Forms/DatePicker/Calendar"; import DatePickerAux, { DatePickerAuxSubProps, } from ":/components/Forms/DatePicker/DatePickerAux"; import DateFieldBox from ":/components/Forms/DatePicker/DateField"; -import { StringsOrDateRange } from ":/components/Forms/DatePicker/types"; import { convertDateValueToString, getDefaultPickerOptions, - parseRangeCalendarDate, + parseRangeDateValue, } from ":/components/Forms/DatePicker/utils"; export type DateRangePickerProps = DatePickerAuxSubProps & { startLabel: string; endLabel: string; - value?: null | StringsOrDateRange; - defaultValue?: StringsOrDateRange; + value?: null | [string, string]; + defaultValue?: [string, string]; onChange?: (value: [string, string] | null) => void; }; @@ -40,8 +40,8 @@ export const DateRangePicker = ({ const options: DateRangePickerStateOptions = { ...getDefaultPickerOptions(props), - value: props.value === null ? null : parseRangeCalendarDate(props.value), - defaultValue: parseRangeCalendarDate(props.defaultValue), + value: props.value === null ? null : parseRangeDateValue(props.value), + defaultValue: parseRangeDateValue(props.defaultValue), onChange: (value: DateRange) => { props.onChange?.( value?.start && value.end diff --git a/packages/react/src/components/Forms/DatePicker/index.stories.tsx b/packages/react/src/components/Forms/DatePicker/index.stories.tsx index c9957a1..d06accf 100644 --- a/packages/react/src/components/Forms/DatePicker/index.stories.tsx +++ b/packages/react/src/components/Forms/DatePicker/index.stories.tsx @@ -4,10 +4,6 @@ import { CunninghamProvider } from ":/components/Provider"; import { Button } from ":/components/Button"; import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker"; import { DatePicker } from ":/components/Forms/DatePicker/DatePicker"; -import { - StringOrDate, - StringsOrDateRange, -} from ":/components/Forms/DatePicker/types"; export default { title: "Components/Forms/DatePicker", @@ -35,18 +31,18 @@ export const Disabled = { export const DefaultValue = { render: Template, - args: { defaultValue: "2023-05-24" }, + args: { defaultValue: "2023-05-24T00:00:00.000+00:00" }, }; export const DisabledValue = { render: Template, - args: { disabled: true, defaultValue: "2023-05-24" }, + args: { disabled: true, defaultValue: "2023-05-24T00:00:00.000+00:00" }, }; export const Error = { render: Template, args: { - defaultValue: "2023-05-24", + defaultValue: "2023-05-24T00:00:00.000+00:00", state: "error", text: "Something went wrong", }, @@ -55,7 +51,7 @@ export const Error = { export const Success = { render: Template, args: { - defaultValue: "2023-05-24", + defaultValue: "2023-05-24T00:00:00.000+00:00", state: "success", text: "Well done", }, @@ -64,25 +60,25 @@ export const Success = { export const MinMaxValue = { render: Template, args: { - defaultValue: "2023-05-24", - minValue: "2023-04-23", - maxValue: "2023-06-23", + defaultValue: "2023-05-24T00:00:00.000+00:00", + minValue: "2023-04-23T00:00:00.000+00:00", + maxValue: "2023-06-23T00:00:00.000+00:00", }, }; export const InvalidValue = { render: Template, args: { - defaultValue: "2023-02-24", - minValue: "2023-04-23", - maxValue: "2023-06-23", + defaultValue: "2023-02-24T00:00:00.000+00:00", + minValue: "2023-04-23T00:00:00.000+00:00", + maxValue: "2023-06-23T00:00:00.000+00:00", }, }; export const WithText = { render: Template, args: { - defaultValue: "2023-05-24", + defaultValue: "2023-05-24T00:00:00.000+00:00", text: "This is a text, you can display anything you want here like warnings, information or errors.", }, }; @@ -90,7 +86,7 @@ export const WithText = { export const Fullwidth = { render: Template, args: { - defaultValue: "2023-05-24", + defaultValue: "2023-05-24T00:00:00.000+00:00", fullWidth: true, }, }; @@ -101,7 +97,7 @@ export const CustomLocale = () => (
@@ -110,13 +106,16 @@ export const CustomLocale = () => ( export const CunninghamLocale = () => (
- +
); export const Controlled = () => { - const [value, setValue] = useState("2023-05-26"); + const [value, setValue] = useState("2023-04-25T12:00:00.000Z"); return (
@@ -152,16 +151,19 @@ export const RangeDefaultValue = () => { ); }; export const RangeControlled = () => { - const [value, setValue] = useState([ - "2023-05-23", - "2023-06-23", + const [value, setValue] = useState<[string, string] | null>([ + "2023-05-23T13:37:00.000+02:00", + "2023-06-23T13:37:00.000+02:00", ]); return (
@@ -170,8 +172,8 @@ export const RangeControlled = () => { setValue(e)} /> diff --git a/packages/react/src/components/Forms/DatePicker/index.tsx b/packages/react/src/components/Forms/DatePicker/index.tsx index 001eb97..a8d71a2 100644 --- a/packages/react/src/components/Forms/DatePicker/index.tsx +++ b/packages/react/src/components/Forms/DatePicker/index.tsx @@ -1,5 +1,4 @@ export * from "./DatePicker"; export * from "./DateRangePicker"; -export * from "./types"; export * from "./utils"; diff --git a/packages/react/src/components/Forms/DatePicker/types.ts b/packages/react/src/components/Forms/DatePicker/types.ts deleted file mode 100644 index 0adbb6b..0000000 --- a/packages/react/src/components/Forms/DatePicker/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type StringOrDate = string | Date; -export type StringsOrDateRange = [StringOrDate, StringOrDate]; diff --git a/packages/react/src/components/Forms/DatePicker/utils.spec.ts b/packages/react/src/components/Forms/DatePicker/utils.spec.ts index cb86013..9fc01dd 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.spec.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.spec.ts @@ -1,27 +1,21 @@ -import { - CalendarDate, - DateValue, - parseAbsolute, - parseDate, -} from "@internationalized/date"; +import { DateValue, parseAbsolute, parseDate } from "@internationalized/date"; import { vi } from "vitest"; import { convertDateValueToString, - parseCalendarDate, - parseRangeCalendarDate, + parseDateValue, + parseRangeDateValue, } from ":/components/Forms/DatePicker/utils"; -import { StringOrDate } from ":/components/Forms/DatePicker/types"; const expectDateToBeEqual = ( - parsedDate: CalendarDate | DateValue | undefined, + parsedDate: DateValue | undefined, expectedYear: number, expectedMonth: number, expectedDay: number, ) => { expect(parsedDate).not.eq(undefined); - expect(parsedDate?.year === expectedYear); - expect(parsedDate?.month === expectedMonth); - expect(parsedDate?.day === expectedDay); + expect(parsedDate?.year).eq(expectedYear); + expect(parsedDate?.month).eq(expectedMonth); + expect(parsedDate?.day).eq(expectedDay); }; vi.mock("@internationalized/date", async () => { @@ -36,124 +30,46 @@ vi.mock("@internationalized/date", async () => { }; }); -describe("parseCalendarDate", () => { - it.each([ - [2023, 4, 12], - [2022, 1, 1], - [2022, 12, 31], - [2022, 5, 2], - ])("parse an iso string date", (year: number, month: number, day: number) => { - const d = new Date(year, month, day); - const parsedDate = parseCalendarDate(d.toISOString()); - expectDateToBeEqual(parsedDate, year, month, day); +describe("parseDateValue", () => { + it("parse a 'YYYY-MM-DDThh:mm:ssZ' date", () => { + const parsedDate = parseDateValue("2023-05-11T00:00:00.000Z"); + expectDateToBeEqual(parsedDate, 2023, 5, 11); + expect(parsedDate?.hour).eq(0); }); - it.each([ - [2023, 4, 12], - [2022, 1, 1], - [2022, 12, 31], - [2022, 5, 2], - ])( - "parse a 'YYYY-MM-DD' date", - (year: number, month: number, day: number) => { - const stringDate = `${year}-${month}-${day}`; - const parsedDate = parseCalendarDate(stringDate); - expectDateToBeEqual(parsedDate, year, month, day); - }, - ); - - it.each([ - [2023, 4, 12], - [2022, 1, 1], - [2022, 12, 31], - [2022, 5, 2], - ])("parse a datetime date", (year: number, month: number, day: number) => { - const date = new Date(year, month, day); - const parsedDate = parseCalendarDate(date); - expectDateToBeEqual(parsedDate, year, month, day); + it("parse a 'YYYY-MM-DDThh:mm:ss±hh:mm' date", () => { + const parsedDate = parseDateValue("2023-05-11T00:00:00.000+00:00"); + expectDateToBeEqual(parsedDate, 2023, 5, 11); + expect(parsedDate?.hour).eq(0); }); it.each([undefined, ""])("parse an empty or null date", (date) => { - const parsedDate = parseCalendarDate(date); + const parsedDate = parseDateValue(date); expect(parsedDate).eq(undefined); }); it.each([ "35/04/2024", + "2023-05-11", "11 janvier 20O2", "22.04.2022", "22-4-2022", "2022-04-1T00:00:00-00:00", "2022-04-01 T00:00:00-00:00", + "2022-04-01T00:00:00.000", ])("parse a wrong date", (wrongFormattedDate) => { - expect(() => parseCalendarDate(wrongFormattedDate)).toThrow( + expect(() => parseDateValue(wrongFormattedDate)).toThrow( "Invalid date format when initializing props on DatePicker component", ); }); - - it.each([ - [4, "2023-04-22"], - [5, "2023-04-30"], - [7, "2023-04-22"], - ])( - "parse date to locale timezone, converted to day before", - (offset: number, dateString: string) => { - // Get the local offset - const localOffset = new Date().getTimezoneOffset() / 60; - const formattedOffset = offset.toLocaleString("en-US", { - minimumIntegerDigits: 2, - }); - - // Create an ISO string in a timezone that is the day after in local timezone - const offsetISODate = `${dateString}T${ - 24 - (offset - localOffset - 1) - }:00:00-${formattedOffset}:00`; - - // Parse this ISO string, that should be converted to local timezone - const parsedDate = parseCalendarDate(offsetISODate); - - // Make sure the ISO string have been converted to the local timezone - const nextDay = parseDate(dateString).add({ days: 1 }); - expect(parsedDate?.compare(nextDay)).eq(0); - }, - ); - - it.each([ - [4, "2023-04-22"], - [5, "2023-04-30"], - [7, "2023-04-22"], - ])( - "parse date to locale timezone, converted to same day", - (offset: number, dateString: string) => { - // Get the local offset - const localOffset = new Date().getTimezoneOffset() / 60; - const formattedOffset = offset.toLocaleString("en-US", { - minimumIntegerDigits: 2, - }); - - // Create an ISO string in a timezone that is the day after in local timezone - const offsetISODate = `${dateString}T${ - 24 - (offset - localOffset + 2) - }:00:00-${formattedOffset}:00`; - - // Parse this ISO string, that should be converted to local timezone - const parsedDate = parseCalendarDate(offsetISODate); - const sameDay = parseDate(dateString); - - // Make sure the ISO string have been converted to the local timezone - expect(parsedDate?.compare(sameDay)).eq(0); - }, - ); }); -describe("parseRangeCalendarDate", () => { - it.each([ - ["2023-03-22", "2023-04-22"], - [new Date(2023, 3, 22), "2023-04-22"], - ["2023-03-22", new Date(2023, 4, 22)], - ["2022-03-22T00:00:00-00:00", "2023-04-22"], - ])("parse a date range", (start: string | Date, end: string | Date) => { - const range = parseRangeCalendarDate([start, end]); +describe("parseRangeDateValue", () => { + it("parse a date range", () => { + const range = parseRangeDateValue([ + "2023-03-22T00:00:00.000Z", + "2023-04-22T00:00:00.000Z", + ]); expectDateToBeEqual(range?.start, 2023, 3, 22); expectDateToBeEqual(range?.end, 2023, 4, 22); }); @@ -163,19 +79,22 @@ describe("parseRangeCalendarDate", () => { ["2023-03-22", ""], ])( "parse a partially null or empty date range", - (start: StringOrDate, end: StringOrDate) => { - expect(parseRangeCalendarDate([start, end])).eq(undefined); + (start: string, end: string) => { + expect(parseRangeDateValue([start, end])).eq(undefined); }, ); it("parse an undefined date range", () => { - expect(parseRangeCalendarDate(undefined)).eq(undefined); + expect(parseRangeDateValue(undefined)).eq(undefined); }); it("parse an inverted date range", () => { // Utils function accepts start date superior to the end date // However, DateRangePicker will trigger an error with the parsed range. - const range = parseRangeCalendarDate(["2023-05-22", "2023-04-22"]); + const range = parseRangeDateValue([ + "2023-05-22T00:00:00.000Z", + "2023-04-22T00:00:00.000Z", + ]); expectDateToBeEqual(range?.start, 2023, 5, 22); expectDateToBeEqual(range?.end, 2023, 4, 22); }); diff --git a/packages/react/src/components/Forms/DatePicker/utils.ts b/packages/react/src/components/Forms/DatePicker/utils.ts index 3cd2bc1..5099aaf 100644 --- a/packages/react/src/components/Forms/DatePicker/utils.ts +++ b/packages/react/src/components/Forms/DatePicker/utils.ts @@ -1,28 +1,21 @@ import { - CalendarDate, DateValue, parseAbsoluteToLocal, - toCalendarDate, ZonedDateTime, toZoned, getLocalTimeZone, } from "@internationalized/date"; import { DateRange } from "react-aria"; -import { - StringOrDate, - StringsOrDateRange, -} from ":/components/Forms/DatePicker/types"; import { DatePickerAuxSubProps } from ":/components/Forms/DatePicker/DatePickerAux"; -export const parseCalendarDate = ( - rawDate: StringOrDate | undefined, -): undefined | CalendarDate => { +export const parseDateValue = ( + rawDate: string | undefined, +): undefined | ZonedDateTime => { if (!rawDate) { return undefined; } try { - const ISODateString = new Date(rawDate).toISOString(); - return toCalendarDate(parseAbsoluteToLocal(ISODateString)); + return parseAbsoluteToLocal(rawDate); } catch (e) { throw new Error( "Invalid date format when initializing props on DatePicker component", @@ -30,15 +23,15 @@ export const parseCalendarDate = ( } }; -export const parseRangeCalendarDate = ( - rawRange: StringsOrDateRange | undefined, +export const parseRangeDateValue = ( + rawRange: [string, string] | undefined, ): DateRange | undefined => { if (!rawRange || !rawRange[0] || !rawRange[1]) { return undefined; } return { - start: parseCalendarDate(rawRange[0])!, - end: parseCalendarDate(rawRange[1])!, + start: parseDateValue(rawRange[0])!, + end: parseDateValue(rawRange[1])!, }; }; @@ -50,14 +43,14 @@ export const convertDateValueToString = (date: DateValue | null): string => { return date ? toZoned(date, localTimezone).toAbsoluteString() : ""; } catch (e) { throw new Error( - "Invalid date format when converting date value on DatePicker component" + "Invalid date format when converting date value on DatePicker component", ); } }; export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({ - minValue: parseCalendarDate(props.minValue), - maxValue: parseCalendarDate(props.maxValue), + minValue: parseDateValue(props.minValue), + maxValue: parseDateValue(props.maxValue), shouldCloseOnSelect: true, granularity: "day", isDisabled: props.disabled,