✨(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:
committed by
aleb_the_flash
parent
0dc46d1144
commit
cd42afb10e
5
.changeset/curly-pants-remain.md
Normal file
5
.changeset/curly-pants-remain.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@openfun/cunningham-react": minor
|
||||
---
|
||||
|
||||
Add a timezone props to date picker components
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user