(react) restrict inputs formats in date picker components

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.
This commit is contained in:
Lebaud Antoine
2023-07-12 22:51:32 +02:00
committed by aleb_the_flash
parent 8cf8e1eba2
commit 0dc46d1144
11 changed files with 403 additions and 324 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
Restrict input formats of date picker components to IS0 strings

View File

@@ -8,7 +8,7 @@ import { Button } from ":/components/Button";
vi.mock("@internationalized/date", async () => {
const mod = await vi.importActual<typeof import("@internationalized/date")>(
"@internationalized/date"
"@internationalized/date",
);
return {
...mod,
@@ -193,7 +193,7 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-04-25"
defaultValue="2023-04-25T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -282,7 +282,7 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-04-05"
defaultValue="2023-04-05T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -317,8 +317,8 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-04-05"
minValue="2022-12-03"
defaultValue="2023-04-05T10:00:00.000Z"
minValue="2022-12-03T10:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -350,12 +350,9 @@ describe("<DatePicker/>", () => {
});
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(
<CunninghamProvider>
@@ -373,8 +370,6 @@ describe("<DatePicker/>", () => {
});
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("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
value={value}
defaultValue="2023-05-24T00:00:00.000Z"
value="2023-05-24T00:00:00.000Z"
/>
</CunninghamProvider>,
),
@@ -413,14 +408,13 @@ describe("<DatePicker/>", () => {
);
it("clears date", async () => {
const defaultValue = new Date("2023-05-24");
const user = userEvent.setup();
render(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-24T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -435,7 +429,7 @@ describe("<DatePicker/>", () => {
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("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-01-01"
minValue="2023-02-01"
defaultValue="2023-01-01T00:00:00.000Z"
minValue="2023-02-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -467,8 +461,8 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-03-01"
maxValue="2023-02-01"
defaultValue="2023-03-01T00:00:00.000Z"
maxValue="2023-02-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -482,9 +476,9 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-03-01"
maxValue="2023-04-01"
minValue="2023-05-01"
defaultValue="2023-03-01T00:00:00.000Z"
maxValue="2023-04-01T00:00:00.000Z"
minValue="2023-05-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -494,7 +488,9 @@ describe("<DatePicker/>", () => {
it("works controlled", async () => {
const user = userEvent.setup();
const Wrapper = () => {
const [value, setValue] = useState<string | null>("2023-04-25");
const [value, setValue] = useState<string | null>(
"2023-04-25T00:00:00.000Z",
);
return (
<CunninghamProvider>
<div>
@@ -513,11 +509,11 @@ describe("<DatePicker/>", () => {
render(<Wrapper />);
// 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("<DatePicker/>", () => {
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("<DatePicker/>", () => {
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("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-03-01"
defaultValue="2023-03-01T00:00:00.000Z"
disabled={true}
/>
</CunninghamProvider>,
@@ -584,7 +580,7 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-03-01"
defaultValue="2023-03-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -649,6 +645,7 @@ describe("<DatePicker/>", () => {
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("<DatePicker/>", () => {
// 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("<DatePicker/>", () => {
});
});
it("submits forms data with a default value", async () => {
let formData: any;
const Wrapper = () => {
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
formData = {
datepicker: data.get("datepicker"),
};
};
return (
<CunninghamProvider>
<div>
<form onSubmit={onSubmit}>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-04-25T12:00:00.000Z"
/>
<Button>Submit</Button>
</form>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-24T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -711,14 +811,13 @@ describe("<DatePicker/>", () => {
});
it("clicks next and previous focused year", async () => {
const defaultValue = new Date("2023-05-24");
const user = userEvent.setup();
render(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-24T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -749,18 +848,15 @@ describe("<DatePicker/>", () => {
});
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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
minValue={minValue}
maxValue={maxValue}
defaultValue="2023-05-24T00:00:00.000Z"
minValue="2023-05-22T00:00:00.000Z"
maxValue="2023-05-26T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -782,18 +878,15 @@ describe("<DatePicker/>", () => {
});
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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
minValue={minValue}
maxValue={maxValue}
defaultValue="2023-05-24T00:00:00.000Z"
minValue="2023-05-22T00:00:00.000Z"
maxValue="2023-05-26T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -816,16 +909,16 @@ describe("<DatePicker/>", () => {
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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue="2023-05-23"
minValue={minValue}
maxValue={maxValue}
defaultValue="2023-05-23T00:00:00.000Z"
minValue={minValue.toISOString()}
maxValue={maxValue.toISOString()}
/>
</CunninghamProvider>,
);
@@ -915,13 +1008,12 @@ describe("<DatePicker/>", () => {
it("selects a focused month", async () => {
const user = userEvent.setup();
const defaultValue = new Date("2023-05-23");
render(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-23T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -952,7 +1044,7 @@ describe("<DatePicker/>", () => {
// Select a month option.
const option: HTMLLIElement = screen.getByRole("option", {
name: "September",
name: "August",
});
await user.click(option);
@@ -961,18 +1053,17 @@ describe("<DatePicker/>", () => {
// 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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-23T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -1013,13 +1104,13 @@ describe("<DatePicker/>", () => {
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(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue={defaultValue.toISOString()}
/>
</CunninghamProvider>,
);
@@ -1053,13 +1144,12 @@ describe("<DatePicker/>", () => {
it("navigate previous focused month with keyboard", async () => {
const user = userEvent.setup();
const defaultValue = new Date("2023-05-01");
render(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -1086,13 +1176,12 @@ describe("<DatePicker/>", () => {
it("navigate next focused month with keyboard", async () => {
const user = userEvent.setup();
const defaultValue = new Date("2023-05-31");
render(
<CunninghamProvider>
<DatePicker
label="Pick a date"
name="datepicker"
defaultValue={defaultValue}
defaultValue="2023-05-31T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -1123,7 +1212,7 @@ describe("<DatePicker/>", () => {
<DatePicker
label="Pick a date"
locale="hi-IN-u-ca-indian"
defaultValue="2023-06-25"
defaultValue="2023-06-25T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -1169,7 +1258,10 @@ describe("<DatePicker/>", () => {
const user = userEvent.setup();
render(
<CunninghamProvider currentLocale="fr-FR">
<DatePicker label="Pick a date" defaultValue="2023-06-25" />
<DatePicker
label="Pick a date"
defaultValue="2023-06-25T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -1216,7 +1308,7 @@ describe("<DatePicker/>", () => {
<CunninghamProvider currentLocale="fr-FR">
<DatePicker
label="Pick a date"
defaultValue="2023-06-25"
defaultValue="2023-06-25T00:00:00.000Z"
locale="hi-IN-u-ca-indian"
/>
</CunninghamProvider>,
@@ -1271,4 +1363,51 @@ describe("<DatePicker/>", () => {
),
).toThrow("Incorrect locale information provided");
});
it("keeps time component while selecting a date", async () => {
const user = userEvent.setup();
const Wrapper = () => {
const [value, setValue] = useState<string | null>(
"2023-04-25T12:00:00.000Z",
);
return (
<CunninghamProvider>
<div>
<div>Value = {value}|</div>
<Button onClick={() => setValue(null)}>Clear</Button>
<DatePicker
label="Pick a date"
name="datepicker"
value={value}
onChange={(e: string | null) => setValue(e)}
/>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
// 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|`);
});
});

View File

@@ -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));
},

View File

@@ -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;

View File

@@ -8,7 +8,7 @@ import { Button } from ":/components/Button";
vi.mock("@internationalized/date", async () => {
const mod = await vi.importActual<typeof import("@internationalized/date")>(
"@internationalized/date"
"@internationalized/date",
);
return {
...mod,
@@ -151,9 +151,8 @@ describe("<DateRangePicker/>", () => {
});
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(
<CunninghamProvider>
@@ -180,7 +179,10 @@ describe("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
@@ -375,7 +377,10 @@ describe("<DateRangePicker/>", () => {
<DateRangePicker
startLabel="Start date"
endLabel="End date"
defaultValue={["2023-01-01", "2023-01-01"]}
defaultValue={[
"2023-01-01T00:00:00.000Z",
"2023-01-01T00:00:00.000Z",
]}
name="datepicker"
/>
</CunninghamProvider>,
@@ -437,7 +442,10 @@ describe("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
@@ -486,7 +494,10 @@ describe("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
@@ -657,8 +668,11 @@ describe("<DateRangePicker/>", () => {
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"]}
/>
</CunninghamProvider>,
),
@@ -668,9 +682,9 @@ describe("<DateRangePicker/>", () => {
});
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("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
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(
<CunninghamProvider>
<DateRangePicker
startLabel="Start date"
endLabel="End date"
name="datepicker"
defaultValue={[start, end]}
/>
</CunninghamProvider>,
);
it("clears date", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<DateRangePicker
startLabel="Start date"
endLabel="End date"
name="datepicker"
defaultValue={[
"2023-05-25T00:00:00.000Z",
"2023-05-27T00:00:00.000Z",
]}
/>
</CunninghamProvider>,
);
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("<DateRangePicker/>", () => {
endLabel="End date"
name="datepicker"
defaultValue={[start, end]}
minValue="2023-02-01"
minValue="2023-02-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -786,8 +801,8 @@ describe("<DateRangePicker/>", () => {
);
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("<DateRangePicker/>", () => {
endLabel="End date"
name="datepicker"
defaultValue={[start, end]}
maxValue="2023-02-01"
maxValue="2023-02-01T00:00:00.000Z"
/>
</CunninghamProvider>,
);
@@ -813,7 +828,10 @@ describe("<DateRangePicker/>", () => {
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}
/>
</CunninghamProvider>,
@@ -846,7 +864,10 @@ describe("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
@@ -864,7 +885,10 @@ describe("<DateRangePicker/>", () => {
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",
]}
/>
</CunninghamProvider>,
);
@@ -903,8 +927,8 @@ describe("<DateRangePicker/>", () => {
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 (
<CunninghamProvider>
@@ -924,7 +948,9 @@ describe("<DateRangePicker/>", () => {
render(<Wrapper />);
// 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("<DateRangePicker/>", () => {
// 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.

View File

@@ -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<DateValue> = {
...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

View File

@@ -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 = () => (
<DatePicker
label="Pick a date"
locale="hi-IN-u-ca-indian"
defaultValue="2023-06-25"
defaultValue="2023-06-25T00:00:00.000+00:00"
/>
</CunninghamProvider>
</div>
@@ -110,13 +106,16 @@ export const CustomLocale = () => (
export const CunninghamLocale = () => (
<div style={{ minHeight: "400px" }}>
<CunninghamProvider currentLocale="fr-FR">
<DatePicker label="Pick a date" defaultValue="2023-06-25" />
<DatePicker
label="Pick a date"
defaultValue="2023-06-25T00:00:00.000+00:00"
/>
</CunninghamProvider>
</div>
);
export const Controlled = () => {
const [value, setValue] = useState<StringOrDate | null>("2023-05-26");
const [value, setValue] = useState<string | null>("2023-04-25T12:00:00.000Z");
return (
<CunninghamProvider>
<div>
@@ -152,16 +151,19 @@ export const RangeDefaultValue = () => {
<DateRangePicker
startLabel="Start date"
endLabel="Due date"
defaultValue={["2023-05-23", "2023-06-23"]}
defaultValue={[
"2023-05-23T00:00:00.000+00:00",
"2023-06-23T00:00:00.000+00:00",
]}
/>
</CunninghamProvider>
);
};
export const RangeControlled = () => {
const [value, setValue] = useState<StringsOrDateRange | null>([
"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 (
<div>
@@ -170,8 +172,8 @@ export const RangeControlled = () => {
<DateRangePicker
startLabel="Start date"
endLabel="Due date"
minValue="2023-01-23"
maxValue="2023-08-23"
minValue="2023-01-23T00:00:00.000+00:00"
maxValue="2023-08-23T00:00:00.000+00:00"
value={value}
onChange={(e) => setValue(e)}
/>

View File

@@ -1,5 +1,4 @@
export * from "./DatePicker";
export * from "./DateRangePicker";
export * from "./types";
export * from "./utils";

View File

@@ -1,2 +0,0 @@
export type StringOrDate = string | Date;
export type StringsOrDateRange = [StringOrDate, StringOrDate];

View File

@@ -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);
});

View File

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