(react) add a timezone props on date picker components

By default, component's timezone is the user locale timezone.
Component now offers a way to set its timezone to any supported
Intl timezone format. Please note that output values from the
component will always be converted to a UTC timezone.
This commit is contained in:
Lebaud Antoine
2023-07-24 22:02:55 +02:00
committed by aleb_the_flash
parent 0dc46d1144
commit cd42afb10e
8 changed files with 221 additions and 33 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
Add a timezone props to date picker components

View File

@@ -401,9 +401,7 @@ describe("<DatePicker/>", () => {
/>
</CunninghamProvider>,
),
).toThrow(
"Invalid date format when initializing props on DatePicker component",
);
).toThrow(/Failed to parse date value:/);
},
);
@@ -542,6 +540,50 @@ describe("<DatePicker/>", () => {
screen.getByText("Value = |");
});
it("has a timezone", async () => {
const user = userEvent.setup();
const Wrapper = () => {
const [value, setValue] = useState<string | null>(null);
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)}
timezone="America/Sao_Paulo"
/>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
// Make sure any value is selected.
screen.getByText("Value = |");
// Open the calendar grid.
const toggleButton = (await screen.findAllByRole("button"))![1];
await user.click(toggleButton);
expectCalendarToBeOpen();
const monthSegment = await screen.getByRole("spinbutton", {
name: /month/,
});
// Select the first segment, month one.
await user.click(monthSegment);
expect(monthSegment).toHaveFocus();
// Type date's value.
await user.keyboard("{5}{1}{2}{2}{0}{2}{3}");
// Make sure value is selected at midnight on America/Sao_Paulo.
screen.getByText(`Value = 2023-05-12T03:00:00.000Z|`);
});
it("renders disabled", async () => {
render(
<CunninghamProvider>

View File

@@ -38,10 +38,10 @@ export const DatePicker = (props: DatePickerProps) => {
// Force clear the component's value when passing null or an empty string.
props.value === "" || props.value === null
? null
: parseDateValue(props.value),
defaultValue: parseDateValue(props.defaultValue),
: parseDateValue(props.value, props.timezone),
defaultValue: parseDateValue(props.defaultValue, props.timezone),
onChange: (value: DateValue | null) => {
props.onChange?.(convertDateValueToString(value));
props.onChange?.(convertDateValueToString(value, props.timezone));
},
};
const pickerState = useDatePickerState(options);

View File

@@ -29,6 +29,7 @@ export type DatePickerAuxSubProps = FieldProps & {
disabled?: boolean;
name?: string;
locale?: string;
timezone?: string;
};
export type DatePickerAuxProps = PropsWithChildren &
@@ -111,19 +112,28 @@ const DatePickerAux = forwardRef(
<input
type="hidden"
name={name && `${name}_start`}
value={convertDateValueToString(pickerState.value.start)}
value={convertDateValueToString(
pickerState.value.start,
props.timezone,
)}
/>
<input
type="hidden"
name={name && `${name}_end`}
value={convertDateValueToString(pickerState.value.end)}
value={convertDateValueToString(
pickerState.value.end,
props.timezone,
)}
/>
</>
) : (
<input
type="hidden"
name={name}
value={convertDateValueToString(pickerState.value)}
value={convertDateValueToString(
pickerState.value,
props.timezone,
)}
/>
)}
<div className="c__date-picker__wrapper__icon">

View File

@@ -698,9 +698,7 @@ describe("<DateRangePicker/>", () => {
/>
</CunninghamProvider>,
),
).toThrow(
"Invalid date format when initializing props on DatePicker component",
);
).toThrow(/Failed to parse date value:/);
});
it("has not a valid range value", async () => {
@@ -991,6 +989,50 @@ describe("<DateRangePicker/>", () => {
screen.getByText("Value = |");
});
it("has a timezone", async () => {
const user = userEvent.setup();
const Wrapper = () => {
const [value, setValue] = useState<[string, string] | null>(null);
return (
<CunninghamProvider>
<div>
<div>Value = {value?.join(" ")}|</div>
<Button onClick={() => setValue(null)}>Clear</Button>
<DateRangePicker
startLabel="Start date"
endLabel="End date"
value={value}
onChange={(e) => setValue(e)}
timezone="America/Sao_Paulo"
/>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
// Make sure any value is selected.
screen.getByText("Value = |");
const allSegments = await screen.getAllByRole("spinbutton");
const startMonthSegment = allSegments![0];
// Select the first segment, month one.
await user.click(startMonthSegment);
expect(startMonthSegment).toHaveFocus();
// Type start date's value.
await user.keyboard("{5}{1}{0}{2}{0}{2}{3}");
// Type end date's value.
await user.keyboard("{5}{1}{2}{2}{0}{2}{3}");
// Make sure values is selected at midnight on America/Sao_Paulo.
screen.getByText(
`Value = 2023-05-10T03:00:00.000Z 2023-05-12T03:00:00.000Z|`,
);
});
it("submits forms data", async () => {
let formData: any;
const Wrapper = () => {

View File

@@ -40,14 +40,17 @@ export const DateRangePicker = ({
const options: DateRangePickerStateOptions<DateValue> = {
...getDefaultPickerOptions(props),
value: props.value === null ? null : parseRangeDateValue(props.value),
defaultValue: parseRangeDateValue(props.defaultValue),
value:
props.value === null
? null
: parseRangeDateValue(props.value, props.timezone),
defaultValue: parseRangeDateValue(props.defaultValue, props.timezone),
onChange: (value: DateRange) => {
props.onChange?.(
value?.start && value.end
? [
convertDateValueToString(value.start),
convertDateValueToString(value.end),
convertDateValueToString(value.start, props.timezone),
convertDateValueToString(value.end, props.timezone),
]
: null,
);

View File

@@ -2,6 +2,7 @@ import { DateValue, parseAbsolute, parseDate } from "@internationalized/date";
import { vi } from "vitest";
import {
convertDateValueToString,
isValidTimeZone,
parseDateValue,
parseRangeDateValue,
} from ":/components/Forms/DatePicker/utils";
@@ -43,6 +44,15 @@ describe("parseDateValue", () => {
expect(parsedDate?.hour).eq(0);
});
it("should parse time to the right timezone", async () => {
const parsedDate = parseDateValue(
"2023-05-11T00:00:00.000Z",
"America/Sao_Paulo",
);
expectDateToBeEqual(parsedDate, 2023, 5, 10);
expect(parsedDate?.hour).eq(21);
});
it.each([undefined, ""])("parse an empty or null date", (date) => {
const parsedDate = parseDateValue(date);
expect(parsedDate).eq(undefined);
@@ -59,9 +69,15 @@ describe("parseDateValue", () => {
"2022-04-01T00:00:00.000",
])("parse a wrong date", (wrongFormattedDate) => {
expect(() => parseDateValue(wrongFormattedDate)).toThrow(
"Invalid date format when initializing props on DatePicker component",
/Failed to parse date value:/,
);
});
it("should raise an error when timezone is invalid", async () => {
expect(() =>
parseDateValue("2023-05-11T00:00:00.000Z", "Invalid/Timezone"),
).toThrow(/Failed to parse date value:/);
});
});
describe("parseRangeDateValue", () => {
@@ -119,4 +135,44 @@ describe("convertDateValueToString", () => {
const result = convertDateValueToString(date);
expect(result).eq("2023-05-24T22:00:00.000Z");
});
it("should convert time to the right timezone", async () => {
const date = parseDate("2023-05-25");
const result = convertDateValueToString(date, "America/Sao_Paulo");
expect(result).eq("2023-05-25T03:00:00.000Z");
});
it("should raise an error when timezone is invalid", async () => {
const date = parseDate("2023-05-25");
expect(() => convertDateValueToString(date, "Invalid/Timezone")).toThrow(
/Failed to convert date value to string:/,
);
});
});
describe("isValidTimeZone", () => {
it.each(["UTC", "Europe/Paris", "America/Sao_Paulo"])(
"should return true when timezone is valid",
(timezone) => {
const isValid = isValidTimeZone(timezone);
expect(isValid).toBe(true);
},
);
it("should return false when timezone is invalid", () => {
const isNotValid = isValidTimeZone("Invalid/Timezone");
expect(isNotValid).toBe(false);
});
it("should return false when Intl or time zones are not available", () => {
// Mock Intl to simulate the absence of Intl or time zones support
const originalDateTimeFormat = Intl.DateTimeFormat;
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => {
throw new Error("Time zones are not available");
});
const result = isValidTimeZone("Europe/Paris");
expect(result).toBe(false);
// Restore the original implementation after the test
(Intl as any).DateTimeFormat = originalDateTimeFormat;
});
});

View File

@@ -4,53 +4,83 @@ import {
ZonedDateTime,
toZoned,
getLocalTimeZone,
parseAbsolute,
} from "@internationalized/date";
import { DateRange } from "react-aria";
import { DatePickerAuxSubProps } from ":/components/Forms/DatePicker/DatePickerAux";
export const isValidTimeZone = (timezone: string) => {
try {
// Check if Intl is available and supports time zones
if (!Intl || !Intl.DateTimeFormat().resolvedOptions().timeZone) {
throw new Error("Time zones are not available in this environment");
}
// Test if the provided time zone is valid
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch (error) {
// If an error occurs, it could be due to an invalid time zone or lack of Intl support
return false;
}
};
export const parseDateValue = (
rawDate: string | undefined,
timezone?: string,
): undefined | ZonedDateTime => {
if (!rawDate) {
return undefined;
}
try {
return parseAbsoluteToLocal(rawDate);
if (timezone && !isValidTimeZone(timezone)) {
throw new Error("Invalid timezone provided.");
}
return timezone
? parseAbsolute(rawDate, timezone)
: parseAbsoluteToLocal(rawDate);
} catch (e) {
throw new Error(
"Invalid date format when initializing props on DatePicker component",
);
const errorMessage = e instanceof Error ? ": " + e.message : ".";
throw new Error("Failed to parse date value" + errorMessage);
}
};
export const parseRangeDateValue = (
rawRange: [string, string] | undefined,
timezone?: string,
): DateRange | undefined => {
if (!rawRange || !rawRange[0] || !rawRange[1]) {
return undefined;
}
return {
start: parseDateValue(rawRange[0])!,
end: parseDateValue(rawRange[1])!,
start: parseDateValue(rawRange[0], timezone)!,
end: parseDateValue(rawRange[1], timezone)!,
};
};
export const convertDateValueToString = (date: DateValue | null): string => {
export const convertDateValueToString = (
date: DateValue | null,
timezone?: string,
): string => {
try {
const localTimezone = getLocalTimeZone();
// If timezone is already set, it would be kept, else the selection is set at midnight
// on the local timezone, then converted to a UTC offset.
return date ? toZoned(date, localTimezone).toAbsoluteString() : "";
if (!date) {
return "";
}
const localTimezone = timezone || getLocalTimeZone();
if (!isValidTimeZone(localTimezone)) {
throw new Error("Invalid timezone provided.");
}
return toZoned(date, localTimezone).toAbsoluteString();
} catch (e) {
throw new Error(
"Invalid date format when converting date value on DatePicker component",
);
const errorMessage = e instanceof Error ? ": " + e.message : ".";
throw new Error("Failed to convert date value to string" + errorMessage);
}
};
export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({
minValue: parseDateValue(props.minValue),
maxValue: parseDateValue(props.maxValue),
minValue: parseDateValue(props.minValue, props.timezone),
maxValue: parseDateValue(props.maxValue, props.timezone),
shouldCloseOnSelect: true,
granularity: "day",
isDisabled: props.disabled,