diff --git a/packages/x/src/DateFieldI18n.tsx b/packages/x/src/DateFieldI18n.tsx new file mode 100644 index 00000000..c4c41763 --- /dev/null +++ b/packages/x/src/DateFieldI18n.tsx @@ -0,0 +1,30 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +import { formatDate } from './formatDate'; + +export type DateFieldI18n = { + required: string; + invalidInput: string; + minDate: (min: Date, pickTime: boolean) => string; + maxDate: (max: Date, pickTime: boolean) => string; +}; + +export const defaultDateFieldI18n: DateFieldI18n = { + required: 'Field is required', + invalidInput: 'Must be date', + minDate: (min, pickTime) => `Date must not be earlier than ${formatDate(min, pickTime)}`, + maxDate: (max, pickTime) => `Date must not be later than ${formatDate(max, pickTime)}`, +}; + +export const DateFieldI18nContext = createContext(defaultDateFieldI18n); + +export type DateFieldI18nContextProviderProps = PropsWithChildren<{ i18n?: Partial }>; + +export const DateFieldI18nContextProvider = ({ i18n, children }: DateFieldI18nContextProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/x/src/formatDate.ts b/packages/x/src/formatDate.ts new file mode 100644 index 00000000..e33ea1e9 --- /dev/null +++ b/packages/x/src/formatDate.ts @@ -0,0 +1,9 @@ +import dayjs from 'dayjs'; + +export const formatDate = (value: Date | null | undefined, pickTime: boolean) => { + if (!(value instanceof Date)) { + return ''; + } + + return dayjs(value).format(`YYYY-MM-DD${pickTime ? ' HH:mm' : ''}`); +}; diff --git a/packages/x/src/useDateField.ts b/packages/x/src/useDateField.ts index 8c9b9799..dad301bf 100644 --- a/packages/x/src/useDateField.ts +++ b/packages/x/src/useDateField.ts @@ -1,8 +1,10 @@ -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; +import { DateFieldI18nContext } from './DateFieldI18n'; +import { formatDate } from './formatDate'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; dayjs.extend(customParseFormat); @@ -17,45 +19,14 @@ const defaultDateTimeFormats = [ 'DD/MM/YYYY HH:mm', ]; -export const defaultLocales: Intl.LocalesArgument = 'EN'; - -export const defaultFormatOptions: Intl.DateTimeFormatOptions = {}; - -const formatDate = ( - value: Date | null | undefined, - locales?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions, -) => { - if (!(value instanceof Date)) { - return ''; - } - - return value.toLocaleString(locales, options); -}; - -export type DateFieldErrorMessages = { - invalidInput: string; - required: string; - earlierThanMinDate: (min: Date, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) => string; - laterThanMaxDate: (max: Date, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) => string; -}; - -export const defaultErrorMessages: DateFieldErrorMessages = { - invalidInput: 'Must be date', - required: 'Field is required', - earlierThanMinDate: (min, locales, options) => `Date must not be earlier than ${formatDate(min, locales, options)}`, - laterThanMaxDate: (max, locales, options) => `Date must not be later than ${formatDate(max, locales, options)}`, -}; - export type DateFieldConfig = FieldConfig & { required?: boolean; minDate?: Date; maxDate?: Date; pickTime?: boolean; - formatDate?: (date: Date | null | undefined) => string; - parseDate?: (text: string) => Date; - errorMessages?: Partial; + formatDate?: (date: Date | null | undefined, pickTime: boolean) => string; + parseDate?: (text: string, pickTime: boolean) => Date; locales?: Intl.LocalesArgument; formatOptions?: Intl.DateTimeFormatOptions; @@ -70,13 +41,12 @@ export const useDateField = ({ required, minDate, maxDate, - pickTime, + pickTime = false, formatDate: formatDateProps, parseDate: parseDateProps, - errorMessages = defaultErrorMessages, - locales = defaultLocales, - formatOptions = defaultFormatOptions, }: DateFieldConfig): DateFieldBag => { + const i18n = useContext(DateFieldI18nContext); + const parseDate = useCallback( (text: string) => { text = text.trim(); @@ -85,32 +55,35 @@ export const useDateField = ({ return null; } - const errorMessage = errorMessages.invalidInput ?? defaultErrorMessages.invalidInput; - const date = dayjs(text, [...defaultDateFormats, ...(pickTime ? defaultDateTimeFormats : [])], true); if (!date.isValid()) { - throw new ConversionError(errorMessage); + throw new ConversionError(i18n.invalidInput); } return date.toDate(); }, - [errorMessages.invalidInput, pickTime], + [i18n.invalidInput, pickTime], ); const format = useCallback( (value: Date | null | undefined) => { if (formatDateProps) { - return formatDateProps(value); + return formatDateProps(value, pickTime); } - return formatDate(value, locales, formatOptions); + return formatDate(value, pickTime); }, - [formatDateProps, formatOptions, locales], + [formatDateProps, pickTime], + ); + + const parse = useCallback( + (text: string) => (parseDateProps ?? parseDate)(text, pickTime), + [parseDate, parseDateProps, pickTime], ); const dateBag = useConverterField({ - parse: parseDateProps ?? parseDate, + parse, format, name, validator, @@ -121,7 +94,7 @@ export const useDateField = ({ name, validator: (value) => { if (required && !(value instanceof Date)) { - return errorMessages.required ?? defaultErrorMessages.required; + return i18n.required; } if (!(value instanceof Date)) { @@ -129,11 +102,11 @@ export const useDateField = ({ } if (minDate instanceof Date && dayjs(minDate).diff(dayjs(value)) > 0) { - return (errorMessages.earlierThanMinDate ?? defaultErrorMessages.earlierThanMinDate)(minDate); + return i18n.minDate(minDate, pickTime); } if (maxDate instanceof Date && dayjs(value).diff(dayjs(maxDate)) > 0) { - return (errorMessages.laterThanMaxDate ?? defaultErrorMessages.laterThanMaxDate)(maxDate); + return i18n.maxDate(maxDate, pickTime); } return undefined; diff --git a/packages/x/tests/useDateField.test.tsx b/packages/x/tests/useDateField.test.tsx index 7ef80a69..a274882d 100644 --- a/packages/x/tests/useDateField.test.tsx +++ b/packages/x/tests/useDateField.test.tsx @@ -2,20 +2,17 @@ import React from 'react'; import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { - DateFieldConfig, - defaultErrorMessages, - defaultFormatOptions, - defaultLocales, - useDateField, -} from '../src/useDateField'; +import { DateFieldI18n, DateFieldI18nContextProvider, defaultDateFieldI18n } from '../src/DateFieldI18n'; +import { formatDate } from '../src/formatDate'; +import { DateFieldConfig, useDateField } from '../src/useDateField'; type Config = Omit & { initialValue?: Date | null; + i18n?: Partial; }; const renderUseDateField = (config: Config = {}) => { - const { initialValue = null, ...initialProps } = config; + const { initialValue = null, i18n, ...initialProps } = config; const formBag = renderHook(() => useForm({ @@ -33,7 +30,9 @@ const renderUseDateField = (config: Config = {}) => { }), { wrapper: ({ children }) => ( - {children} + + {children} + ), initialProps, }, @@ -43,12 +42,12 @@ const renderUseDateField = (config: Config = {}) => { }; describe('Date field', () => { - it.skip('Should format initial value correctly', () => { + it('Should format initial value correctly', () => { const initialValue = new Date(); const [{ result }] = renderUseDateField({ initialValue }); - expect(result.current.text).toBe(initialValue.toLocaleString(defaultLocales, defaultFormatOptions)); + expect(result.current.text).toBe(formatDate(initialValue, false)); expect(result.current.value?.getTime()).toBe(initialValue.getTime()); }); @@ -60,7 +59,7 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.invalidInput); }); await act(() => { @@ -68,7 +67,7 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.invalidInput); }); await act(() => { @@ -76,7 +75,7 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.invalidInput); }); await act(() => { @@ -115,7 +114,7 @@ describe('Date field', () => { }); }); - it.skip('Should set default error if field is required and empty', async () => { + it('Should set default error if field is required and empty', async () => { const [{ result }] = renderUseDateField({ required: true }); act(() => { @@ -123,11 +122,11 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.required); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.required); }); }); - it.skip('Should set default error if date is earlier than minDate', async () => { + it('Should set default error if date is earlier than minDate', async () => { const minDate = new Date(2000, 0, 5); const [{ result }] = renderUseDateField({ minDate }); @@ -136,7 +135,7 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.earlierThanMinDate(minDate)); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.minDate(minDate, false)); }); await act(() => { @@ -148,7 +147,7 @@ describe('Date field', () => { }); }); - it.skip('Should set default error if date is later than maxDate', async () => { + it('Should set default error if date is later than maxDate', async () => { const maxDate = new Date(2000, 0, 5); const [{ result }] = renderUseDateField({ maxDate }); @@ -157,7 +156,7 @@ describe('Date field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.laterThanMaxDate(maxDate)); + expect(result.current.meta.error?.$error).toBe(defaultDateFieldI18n.maxDate(maxDate, false)); }); await act(() => { @@ -169,9 +168,9 @@ describe('Date field', () => { }); }); - it.skip('Should set custom conversion error correctly', async () => { + it('Should set custom conversion error correctly', async () => { const [{ result }] = renderUseDateField({ - errorMessages: { + i18n: { invalidInput: 'custom', }, }); @@ -201,10 +200,12 @@ describe('Date field', () => { }); }); - it.skip('Should set custom error if field is required and empty', async () => { + it('Should set custom error if field is required and empty', async () => { const [{ result }] = renderUseDateField({ required: true, - errorMessages: { required: 'custom' }, + i18n: { + required: 'custom', + }, }); act(() => { @@ -216,56 +217,60 @@ describe('Date field', () => { }); }); - // it.skip('Should set custom error if field value is less than min', async () => { - // const [{ result }] = renderUseDateField({ - // min: 0.5, - // errorMessages: { lessThanMinValue: () => 'custom' }, - // }); - - // act(() => { - // result.current.control.setValue(0.25); - // }); - - // await waitFor(() => { - // expect(result.current.meta.error?.$error).toBe('custom'); - // }); - // }); - - // it.skip('Should set custom error if field value is more than max', async () => { - // const [{ result }] = renderUseDateField({ - // max: 0.5, - // errorMessages: { moreThanMaxValue: () => 'custom' }, - // }); - - // act(() => { - // result.current.control.setValue(0.75); - // }); - - // await waitFor(() => { - // expect(result.current.meta.error?.$error).toBe('custom'); - // }); - // }); - - // it.skip('Should be able to format decimal differently', () => { - // const formatValue = jest.fn(() => 'custom'); - // const initialValue = 3.14; - // const [{ result }] = renderUseDateField({ formatValue, initialValue }); - - // expect(result.current.text).toBe('custom'); - // expect(formatValue).toBeCalledWith(initialValue); - // }); - - // it.skip('Should call custom parseDecimal function', async () => { - // const parseDecimal = jest.fn(); - - // const [{ result }] = renderUseDateField({ parseDecimal }); - - // await act(() => { - // result.current.onTextChange('0.0'); - // }); - - // await waitFor(() => { - // expect(parseDecimal).toBeCalledWith('0.0'); - // }); - // }); + it('Should set custom error if date is earlier than min date', async () => { + const [{ result }] = renderUseDateField({ + minDate: new Date(42), + i18n: { + minDate: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue(new Date(41)); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if date is later than max date', async () => { + const [{ result }] = renderUseDateField({ + maxDate: new Date(42), + i18n: { + maxDate: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue(new Date(43)); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should be able to format date differently', () => { + const formatDate = jest.fn(() => 'custom'); + const initialValue = new Date(); + const [{ result }] = renderUseDateField({ formatDate, initialValue }); + + expect(result.current.text).toBe('custom'); + expect(formatDate).toBeCalledWith(initialValue, false); + }); + + it('Should call custom parseDate function', async () => { + const parseDate = jest.fn(); + + const [{ result }] = renderUseDateField({ parseDate }); + + await act(() => { + result.current.onTextChange('0.0'); + }); + + await waitFor(() => { + expect(parseDate).toBeCalledWith('0.0', false); + }); + }); });