Skip to content

Commit

Permalink
✨(react) add a timezone props on date picker components
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lebaudantoine committed Aug 2, 2023
1 parent 0dc46d1 commit cd42afb
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-pants-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---

Add a timezone props to date picker components
48 changes: 45 additions & 3 deletions packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,7 @@ describe("<DatePicker/>", () => {
/>
</CunninghamProvider>,
),
).toThrow(
"Invalid date format when initializing props on DatePicker component",
);
).toThrow(/Failed to parse date value:/);
},
);

Expand Down Expand Up @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/components/Forms/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type DatePickerAuxSubProps = FieldProps & {
disabled?: boolean;
name?: string;
locale?: string;
timezone?: string;
};

export type DatePickerAuxProps = PropsWithChildren &
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
58 changes: 57 additions & 1 deletion packages/react/src/components/Forms/DatePicker/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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;
});
});
62 changes: 46 additions & 16 deletions packages/react/src/components/Forms/DatePicker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit cd42afb

Please sign in to comment.