From aed6c43bb4d33b0b65bfa6898a3b25d1ce485ac0 Mon Sep 17 00:00:00 2001 From: Damien Robson Date: Fri, 25 Oct 2024 10:30:40 +0100 Subject: [PATCH] feat(date, date-range): provides a mechanism that allows consumers to override the format of dates A new property has been added to the locale interface for dates to allow customers to override the displayed date formats in both the Date and DateRange inputs fix #6930 --- .../date-range/date-range.component.tsx | 8 +- src/components/date-range/date-range.mdx | 9 +- .../date-range/date-range.stories.tsx | 48 ++++- src/components/date-range/date-range.test.tsx | 69 +++++++ .../date-formats/date-formats.test.ts | 30 +++ .../date/__internal__/date-formats/index.ts | 31 ++- src/components/date/date-test.stories.tsx | 56 +++++- src/components/date/date.component.tsx | 8 +- src/components/date/date.mdx | 9 +- src/components/date/date.stories.tsx | 41 ++++ src/components/date/date.test.tsx | 180 ++++++++++++++++++ src/locales/de-de.ts | 1 + src/locales/en-ca.ts | 1 + src/locales/en-gb.ts | 2 + src/locales/en-us.ts | 1 + src/locales/es-es.ts | 1 + src/locales/fr-ca.ts | 1 + src/locales/fr-fr.ts | 1 + src/locales/locale.ts | 1 + 19 files changed, 487 insertions(+), 11 deletions(-) diff --git a/src/components/date-range/date-range.component.tsx b/src/components/date-range/date-range.component.tsx index 8dca31fc7d..ff1dde9a08 100644 --- a/src/components/date-range/date-range.component.tsx +++ b/src/components/date-range/date-range.component.tsx @@ -116,6 +116,8 @@ export interface DateRangeProps required?: boolean; /** Flag to configure component as optional. */ isOptional?: boolean; + /** Date format string to be applied to the date inputs */ + dateFormatOverride?: string; } export const DateRange = ({ @@ -141,10 +143,10 @@ export const DateRange = ({ : labelsInline; const l = useLocale(); - const { dateFnsLocale } = l.date; + const { dateFnsLocale, dateFormatOverride } = l.date; const { format } = useMemo( - () => getFormatData(dateFnsLocale()), - [dateFnsLocale], + () => getFormatData(dateFnsLocale(), dateFormatOverride), + [dateFnsLocale, dateFormatOverride], ); const inlineLabelWidth = 40; const [lastChangedDate, setLastChangedDate] = useState(""); diff --git a/src/components/date-range/date-range.mdx b/src/components/date-range/date-range.mdx index 8fdc09fbb4..89e3ed7e6a 100644 --- a/src/components/date-range/date-range.mdx +++ b/src/components/date-range/date-range.mdx @@ -39,7 +39,7 @@ import DateRange from "carbon-react/lib/components/date-range"; ### LabelsInline -**Note:** The `labelsInline` prop is not supported if the `validationRedesignOptIn` flag on the `CarbonProvider` is true. +**Note:** The `labelsInline` prop is not supported if the `validationRedesignOptIn` flag on the `CarbonProvider` is true. @@ -110,6 +110,13 @@ the French locale. Required locales can be imported like so `import { fr } from +### Locale format override + +You can also override the format used to display dates in the date picker via the `dateFormatOverride` property. In the example below, the German locale is used +but the date format has been overridden to `y-m-ddd` as opposed to the default `dd.MM.yyyy`. + + + ## Props ### Date Range diff --git a/src/components/date-range/date-range.stories.tsx b/src/components/date-range/date-range.stories.tsx index 93b90dcd39..0752198b82 100644 --- a/src/components/date-range/date-range.stories.tsx +++ b/src/components/date-range/date-range.stories.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Meta, StoryObj } from "@storybook/react"; -import { fr } from "date-fns/locale"; +import { fr, de } from "date-fns/locale"; import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; @@ -371,3 +371,49 @@ export const IsOptional: Story = () => { ); }; IsOptional.storyName = "IsOptional"; + +export const LocaleFormatOverrideExampleImplementation: Story = ({ + ...args +}) => { + const [state, setState] = useState(["2016-10-01", "2016-10-30"]); + const handleChange = (ev: DateRangeChangeEvent) => { + const newValue = [ + ev.target.value[0].formattedValue, + ev.target.value[1].formattedValue, + ]; + setState(newValue); + }; + + return ( +
+ "de-DE", + date: { + dateFnsLocale: () => de, + ariaLabels: { + previousMonthButton: () => "Vorheriger Monat", + nextMonthButton: () => "Nächster Monat", + }, + dateFormatOverride: args.dateFormatOverride || "dd-MM-yyyy", + }, + }} + > + + +
+ ); +}; +LocaleFormatOverrideExampleImplementation.storyName = + "Locale Format Override Example Implementation"; +LocaleFormatOverrideExampleImplementation.parameters = { + chromatic: { disableSnapshot: true }, +}; +LocaleFormatOverrideExampleImplementation.args = { + dateFormatOverride: "d-M-yyyy", +}; diff --git a/src/components/date-range/date-range.test.tsx b/src/components/date-range/date-range.test.tsx index 593b32bb39..b52d4a946a 100644 --- a/src/components/date-range/date-range.test.tsx +++ b/src/components/date-range/date-range.test.tsx @@ -1,10 +1,13 @@ import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import enGBLocale from "date-fns/locale/en-GB"; +import deLocale from "date-fns/locale/de"; import DateRange, { DateRangeChangeEvent } from "./date-range.component"; import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils"; import CarbonProvider from "../carbon-provider"; +import I18nProvider from "../i18n-provider"; testStyledSystemMargin( (props) => ( @@ -1136,3 +1139,69 @@ test("should have the default styling when the `labelsInline` prop is set and `v expect(screen.getByTestId("start")).toHaveStyle("vertical-align: bottom"); expect(screen.getByTestId("end")).toHaveStyle("vertical-align: bottom"); }); + +describe("Locale formatting overrides", () => { + test("should render with the input value matching the expected format when `dateFormatOverride` is set and the language is `de-DE`", () => { + render( + "de-DE", + date: { + ariaLabels: { + nextMonthButton: () => "foo", + previousMonthButton: () => "foo", + }, + dateFnsLocale: () => deLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} + /> + , + ); + + expect(screen.getByRole("textbox", { name: "start" })).toHaveValue( + "2016-0-010", + ); + expect(screen.getByRole("textbox", { name: "end" })).toHaveValue( + "2016-0-011", + ); + }); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set and the language is `en-GB`", () => { + render( + "en-GB", + date: { + ariaLabels: { + nextMonthButton: () => "foo", + previousMonthButton: () => "foo", + }, + dateFnsLocale: () => enGBLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} + /> + , + ); + + expect(screen.getByRole("textbox", { name: "start" })).toHaveValue( + "2016-0-010", + ); + expect(screen.getByRole("textbox", { name: "end" })).toHaveValue( + "2016-0-011", + ); + }); +}); diff --git a/src/components/date/__internal__/date-formats/date-formats.test.ts b/src/components/date/__internal__/date-formats/date-formats.test.ts index 099313074f..9d06020b0d 100644 --- a/src/components/date/__internal__/date-formats/date-formats.test.ts +++ b/src/components/date/__internal__/date-formats/date-formats.test.ts @@ -510,6 +510,36 @@ test("should default to en-GB locale if no locale code string passed to `getForm expect(format).toEqual(formatMap["en-GB"]); }); +describe("dateFormatOverride tests", () => { + const localeCodes = [ + // handled locales + "en-CA", + "en-US", + "en-ZA", + "fr-CA", + "ar-EG", + // random locale to cover default switch scenario + "zh-HK", + ]; + const dateFormatOverride = "dd Mo yyyy"; + + test.each(localeCodes)( + "should support %s locale code string passed to `getFormatData` when dateFormatOverride is provided", + (code: string) => { + const { formats, format } = getFormatData({ code }, dateFormatOverride); + + const expectedFormats = getExpectedFormatForLocale(code); + + expect( + expectedFormats.every((formatStr) => formats.includes(formatStr)) && + formats.length === expectedFormats.length, + ).toEqual(true); + + expect(format).toEqual(dateFormatOverride); + }, + ); +}); + describe.each(euLocales)( "when EU locales are passed to `getFormatData`", (locale: string) => { diff --git a/src/components/date/__internal__/date-formats/index.ts b/src/components/date/__internal__/date-formats/index.ts index e624320a92..4de6900ef0 100644 --- a/src/components/date/__internal__/date-formats/index.ts +++ b/src/components/date/__internal__/date-formats/index.ts @@ -140,7 +140,36 @@ interface LocaleFormats { format: string; } -const getFormatData = ({ code = "en-GB" }: DateFnsLocale): LocaleFormats => { +const getFormatData = ( + { code = "en-GB" }: DateFnsLocale, + dateFormatOverride?: string, +): LocaleFormats => { + if (dateFormatOverride) { + const { format } = getOutputFormatForLocale(code); + let formatFromLocale; + + switch (code) { + case "en-CA": + case "en-US": + formatFromLocale = "MM/dd/yyyy"; + break; + case "ar-EG": + case "en-ZA": + case "fr-CA": + formatFromLocale = "dd/MM/yyyy"; + break; + default: + formatFromLocale = format; + } + + const formatsForLocale = getInputFormatsArrayForLocale(formatFromLocale); + + return { + format: dateFormatOverride, + formats: generateFormats(formatsForLocale, "/"), + }; + } + if (["en-CA", "en-US"].includes(code)) { const format = "MM/dd/yyyy"; const formats = getInputFormatsArrayForLocale(format); diff --git a/src/components/date/date-test.stories.tsx b/src/components/date/date-test.stories.tsx index bf6c5eab9d..7134983460 100644 --- a/src/components/date/date-test.stories.tsx +++ b/src/components/date/date-test.stories.tsx @@ -1,7 +1,8 @@ import React, { useState } from "react"; import { action } from "@storybook/addon-actions"; - import { StoryObj } from "@storybook/react"; +import deLocale from "date-fns/locale/de"; + import DateInput, { DateChangeEvent } from "./date.component"; import { CommonTextboxArgs, @@ -12,6 +13,7 @@ import { import CarbonProvider from "../carbon-provider/carbon-provider.component"; import Box from "../box"; import Confirm from "../confirm"; +import I18nProvider from "../i18n-provider"; export default { title: "Date Input/Test", @@ -148,3 +150,55 @@ export const MultipleDates: StoryObj = () => { MultipleDates.storyName = "Multiple Dates with onPickerOpen and onPickerClose callbacks"; MultipleDates.parameters = { chromatic: { disableSnapshot: true } }; + +interface I18nArgs extends CommonTextboxArgs { + /** The format used to override te displayed date format */ + dateFormatOverride?: string; +} + +export const I18NStory = (args: I18nArgs) => { + const [state, setState] = useState("2019-04-05"); + const setValue = (ev: DateChangeEvent) => { + action("onChange")(ev.target.value); + setState(ev.target.value.formattedValue); + }; + return ( + + "de-DE", + date: { + ariaLabels: { + nextMonthButton: () => "foo", + previousMonthButton: () => "foo", + }, + dateFnsLocale: () => deLocale, + dateFormatOverride: args.dateFormatOverride || "dd/MM/yyyy", + }, + }} + > + { + action("onBlur")(ev.target.value); + }} + onKeyDown={(ev) => + action("onKeyDown")((ev.target as HTMLInputElement).value) + } + onClick={(ev) => + action("onClick")((ev.target as HTMLInputElement).value) + } + {...getCommonTextboxArgsWithSpecialCaracters(args)} + /> + + + ); +}; +I18NStory.storyName = "i18n Story"; +I18NStory.args = { + dateFormatOverride: "dd/MM/yyyy", + ...getCommonTextboxArgs(), +}; diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index fa9755f5a7..7205cfaaa3 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -101,6 +101,8 @@ export interface DateInputProps onPickerOpen?: () => void; /** Callback triggered when the picker is closed */ onPickerClose?: () => void; + /** Date format string to be applied to the date inputs */ + dateFormatOverride?: string; } export const DateInput = React.forwardRef( @@ -146,10 +148,10 @@ export const DateInput = React.forwardRef( const focusedViaPicker = useRef(false); const blockClose = useRef(false); const locale = useLocale(); - const { dateFnsLocale } = locale.date; + const { dateFnsLocale, dateFormatOverride } = locale.date; const { format, formats } = useMemo( - () => getFormatData(dateFnsLocale()), - [dateFnsLocale], + () => getFormatData(dateFnsLocale(), dateFormatOverride), + [dateFnsLocale, dateFormatOverride], ); const { inputRefMap, setInputRefMap } = useContext(DateRangeContext); const [open, setOpen] = useState(false); diff --git a/src/components/date/date.mdx b/src/components/date/date.mdx index 599a0a32ba..cb36dd38b8 100644 --- a/src/components/date/date.mdx +++ b/src/components/date/date.mdx @@ -66,7 +66,7 @@ be used to set the input value like in the example below, although the component ### With labelInline -**Note:** The `labelInline` prop is not supported if the `validationRedesignOptIn` flag on the `CarbonProvider` is true. +**Note:** The `labelInline` prop is not supported if the `validationRedesignOptIn` flag on the `CarbonProvider` is true. @@ -149,6 +149,13 @@ the German (`de`) locale whilst the second is for a Chinese (`zh-CN`) locale. Re +### Locale format override + +You can also override the format used to display dates in the date picker via the `dateFormatOverride` property. In the example below, the German locale is used +but the date format has been overridden to `yyyy-M-d` as opposed to the default `dd.MM.yyyy`. + + + ## Props ### DateInput diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index 07876db87f..9df69d32cc 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -522,3 +522,44 @@ LocaleOverrideExampleImplementation.storyName = LocaleOverrideExampleImplementation.parameters = { chromatic: { disableSnapshot: true }, }; + +export const LocaleFormatOverrideExampleImplementation: Story = ({ + ...args +}) => { + const [state, setState] = useState("2022-04-05"); + const handleChange = (ev: DateChangeEvent) => { + console.log(ev.target.value); + setState(ev.target.value.formattedValue); + }; + return ( +
+ "de-DE", + date: { + dateFnsLocale: () => de, + ariaLabels: { + previousMonthButton: () => "Vorheriger Monat", + nextMonthButton: () => "Nächster Monat", + }, + dateFormatOverride: args.dateFormatOverride || "dd-MM-yyyy", + }, + }} + > + + +
+ ); +}; +LocaleFormatOverrideExampleImplementation.storyName = + "Locale Format Override - Example Implementation"; +LocaleFormatOverrideExampleImplementation.parameters = { + chromatic: { disableSnapshot: true }, +}; +LocaleFormatOverrideExampleImplementation.args = { + dateFormatOverride: "d-M-yyyy", +}; diff --git a/src/components/date/date.test.tsx b/src/components/date/date.test.tsx index d69a2bb395..2ee4aa1db3 100644 --- a/src/components/date/date.test.tsx +++ b/src/components/date/date.test.tsx @@ -783,6 +783,25 @@ describe("when the `locale` is 'en-GB''", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "en-GB", + date: { + ariaLabels, + dateFnsLocale: () => enGBLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'de-DE'", () => { @@ -844,6 +863,25 @@ describe("when the `locale` is 'de-DE'", () => { expect(input).toHaveValue("04.04.2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "de-DE", + date: { + ariaLabels, + dateFnsLocale: () => deLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'es'", () => { @@ -905,6 +943,25 @@ describe("when the `locale` is 'es'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "es", + date: { + ariaLabels, + dateFnsLocale: () => esLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'en-ZA'", () => { @@ -966,6 +1023,25 @@ describe("when the `locale` is 'en-ZA'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "en-ZA", + date: { + ariaLabels, + dateFnsLocale: () => enZALocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'fr-FR'", () => { @@ -1027,6 +1103,25 @@ describe("when the `locale` is 'fr-FR'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "fr-FR", + date: { + ariaLabels, + dateFnsLocale: () => frLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'fr-CA'", () => { @@ -1088,6 +1183,25 @@ describe("when the `locale` is 'fr-CA'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "fr-CA", + date: { + ariaLabels, + dateFnsLocale: () => frCALocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'en-CA'", () => { @@ -1149,6 +1263,25 @@ describe("when the `locale` is 'en-CA'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "en-CA", + date: { + ariaLabels, + dateFnsLocale: () => enCALocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); describe("when the `locale` is 'en-US'", () => { @@ -1210,6 +1343,25 @@ describe("when the `locale` is 'en-US'", () => { expect(input).toHaveValue("04/04/2019"); }, ); + + test("should render with the input value matching the expected format when `dateFormatOverride` is set", () => { + render( + "en-US", + date: { + ariaLabels, + dateFnsLocale: () => enUSLocale, + dateFormatOverride: "y-m-ddd", + }, + }} + > + {}} value="2019-04-05" /> + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("2019-0-005"); + }); }); test("should update the input value and call `onChange` with expected parameters when the user types a string with a valid leap year and the input is blurred", async () => { @@ -1491,3 +1643,31 @@ test("should call `onPickerOpen` callback when the user opens the DatePicker and await user.click(document.body); expect(onPickerClose).toHaveBeenCalled(); }); + +test("should select the correct date when the locale is overridden and a date is typed into the input", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + + render( + "de-DE", + date: { + ariaLabels, + dateFnsLocale: () => deLocale, + dateFormatOverride: "dd/MM/yyyy", + }, + }} + > + + , + ); + const input = screen.getByRole("textbox"); + await user.click(input); + jest.advanceTimersByTime(10); + await user.type(input, "05/04"); + jest.advanceTimersByTime(10); + + const grid = screen.getByRole("grid").childNodes[0].textContent; + expect(grid).toEqual("April 2019"); +}); diff --git a/src/locales/de-de.ts b/src/locales/de-de.ts index f743ab3c4c..5329c785d7 100644 --- a/src/locales/de-de.ts +++ b/src/locales/de-de.ts @@ -1,4 +1,5 @@ import deDEDateLocale from "date-fns/locale/de"; + import Locale from "./locale"; const isSingular = (count: string | number): boolean => diff --git a/src/locales/en-ca.ts b/src/locales/en-ca.ts index e7ea079a85..6ef1bf16eb 100644 --- a/src/locales/en-ca.ts +++ b/src/locales/en-ca.ts @@ -1,4 +1,5 @@ import enCADateLocale from "date-fns/locale/en-CA"; + import Locale from "./locale"; const enCA: Partial = { diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts index 41fbecd520..760155487b 100644 --- a/src/locales/en-gb.ts +++ b/src/locales/en-gb.ts @@ -1,4 +1,5 @@ import enGBDateLocale from "date-fns/locale/en-GB"; + import Locale from "./locale"; const isSingular = (count: string | number): boolean => @@ -46,6 +47,7 @@ const enGB: Locale = { previousMonthButton: () => "Previous month", nextMonthButton: () => "Next month", }, + dateFormatOverride: undefined, }, dialog: { ariaLabels: { diff --git a/src/locales/en-us.ts b/src/locales/en-us.ts index 157e0b5c67..90d2a40237 100644 --- a/src/locales/en-us.ts +++ b/src/locales/en-us.ts @@ -1,4 +1,5 @@ import enUSDateLocale from "date-fns/locale/en-US"; + import Locale from "./locale"; const enUS: Partial = { diff --git a/src/locales/es-es.ts b/src/locales/es-es.ts index 4f457cb259..7e372052ef 100644 --- a/src/locales/es-es.ts +++ b/src/locales/es-es.ts @@ -1,4 +1,5 @@ import esESDateLocale from "date-fns/locale/es"; + import Locale from "./locale"; const isSingular = (count: string | number): boolean => diff --git a/src/locales/fr-ca.ts b/src/locales/fr-ca.ts index 2e0106b250..66a3010e04 100644 --- a/src/locales/fr-ca.ts +++ b/src/locales/fr-ca.ts @@ -1,4 +1,5 @@ import frCADateLocale from "date-fns/locale/fr-CA"; + import Locale from "./locale"; const isSingular = (count: string | number): boolean => diff --git a/src/locales/fr-fr.ts b/src/locales/fr-fr.ts index 896452bbd2..a0e601ba7b 100644 --- a/src/locales/fr-fr.ts +++ b/src/locales/fr-fr.ts @@ -1,4 +1,5 @@ import frFRDateLocale from "date-fns/locale/fr"; + import Locale from "./locale"; const isSingular = (count: string | number): boolean => diff --git a/src/locales/locale.ts b/src/locales/locale.ts index 01638468e0..5d7ce67cc7 100644 --- a/src/locales/locale.ts +++ b/src/locales/locale.ts @@ -35,6 +35,7 @@ interface Locale { previousMonthButton: () => string; nextMonthButton: () => string; }; + dateFormatOverride?: string; }; dialog: { ariaLabels: {