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,