diff --git a/.changeset/red-badgers-doubt.md b/.changeset/red-badgers-doubt.md new file mode 100644 index 00000000..36d892b7 --- /dev/null +++ b/.changeset/red-badgers-doubt.md @@ -0,0 +1,5 @@ +--- +'@reactive-forms/x': minor +--- + +Created useIntegerField hook diff --git a/packages/x/src/IntegerFieldI18n.tsx b/packages/x/src/IntegerFieldI18n.tsx new file mode 100644 index 00000000..0a8fe0ee --- /dev/null +++ b/packages/x/src/IntegerFieldI18n.tsx @@ -0,0 +1,30 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +export type IntegerFieldI18n = { + required: string; + invalidInput: string; + minValue: (value: number) => string; + maxValue: (value: number) => string; +}; + +export const defaultIntegerFieldI18n: IntegerFieldI18n = { + required: 'Field is required', + invalidInput: 'Must be integer', + minValue: (min: number) => `Value should not be less than ${min.toFixed(0)}`, + maxValue: (max: number) => `Value should not be greater than ${max.toFixed(0)}`, +}; + +export const IntegerFieldI18nContext = createContext(defaultIntegerFieldI18n); + +export type IntegerFieldI18nContextProviderProps = PropsWithChildren<{ + i18n?: Partial; +}>; + +export const IntegerFieldI18nContextProvider = ({ children, i18n }: IntegerFieldI18nContextProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/x/src/formatInteger.ts b/packages/x/src/formatInteger.ts new file mode 100644 index 00000000..49a9a970 --- /dev/null +++ b/packages/x/src/formatInteger.ts @@ -0,0 +1,7 @@ +export const formatInteger = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return ''; + } + + return value.toFixed(0); +}; diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index c0fdc236..b230d679 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -8,10 +8,12 @@ export class ConversionError extends Error { } } -export type ConverterFieldConfig = { +export type ValueConverter = { parse: (value: string) => T; format: (value: T) => string; -} & FieldConfig; +}; + +export type ConverterFieldConfig = ValueConverter & FieldConfig; export type ConverterFieldBag = { text: string; diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts new file mode 100644 index 00000000..8800b467 --- /dev/null +++ b/packages/x/src/useIntegerField.ts @@ -0,0 +1,89 @@ +import { useCallback, useContext } from 'react'; +import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; + +import { formatInteger } from './formatInteger'; +import { IntegerFieldI18nContext } from './IntegerFieldI18n'; +import { ConversionError, ConverterFieldBag, useConverterField, ValueConverter } from './useConverterField'; + +const INTEGER_REGEX = /^-?\d+$/; + +export type IntegerFieldConfig = FieldConfig & { + required?: boolean; + min?: number; + max?: number; +} & Partial>; + +export type IntegerFieldBag = ConverterFieldBag; + +export const useIntegerField = ({ + name, + validator, + schema, + required, + min, + max, + parse: customParse, + format = formatInteger, +}: IntegerFieldConfig): IntegerFieldBag => { + const i18n = useContext(IntegerFieldI18nContext); + + const parse = useCallback( + (text: string) => { + text = text.trim(); + + if (customParse) { + return customParse(text); + } + + if (text.length === 0) { + return null; + } + + if (!INTEGER_REGEX.test(text)) { + throw new ConversionError(i18n.invalidInput); + } + + const value = Number.parseInt(text); + + if (Number.isNaN(value)) { + throw new ConversionError(i18n.invalidInput); + } + + return value; + }, + [customParse, i18n.invalidInput], + ); + + const integerBag = useConverterField({ + parse, + format, + name, + validator, + schema, + }); + + useFieldValidator({ + name, + validator: (value) => { + if (required && typeof value !== 'number') { + return i18n.required; + } + + if (typeof value !== 'number') { + return undefined; + } + + if (typeof min === 'number' && value < min) { + return i18n.minValue(min); + } + + if (typeof max === 'number' && value > max) { + return i18n.maxValue(max); + } + + return undefined; + }, + }); + + return integerBag; +}; diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx new file mode 100644 index 00000000..88767015 --- /dev/null +++ b/packages/x/tests/useIntegerField.test.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { defaultIntegerFieldI18n, IntegerFieldI18n, IntegerFieldI18nContextProvider } from '../src/IntegerFieldI18n'; +import { IntegerFieldConfig, useIntegerField } from '../src/useIntegerField'; + +type Config = Omit & { + initialValue?: number | null; + i18n?: Partial; +}; + +const renderUseIntegerField = (config: Config = {}) => { + const { initialValue = 0, i18n, ...initialProps } = config; + + const formBag = renderHook(() => + useForm({ + initialValues: { + test: initialValue, + }, + }), + ); + + const result = renderHook( + (props: Omit) => + useIntegerField({ + name: formBag.result.current.paths.test, + ...props, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps, + }, + ); + + return [result, formBag] as const; +}; + +describe('Integer field', () => { + it('Should format initial value correctly', () => { + const [{ result }] = renderUseIntegerField(); + + expect(result.current.text).toBe('0'); + expect(result.current.value).toBe(0); + }); + + it('Should set default error in case of conversion error and clear it afterwards', async () => { + const [{ result }] = renderUseIntegerField(); + + act(() => { + result.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput); + }); + + act(() => { + result.current.onTextChange('a0'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput); + }); + + act(() => { + result.current.onTextChange('1'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(1); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if field is required and empty', async () => { + const [{ result }] = renderUseIntegerField({ required: true }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.required); + }); + }); + + it('Should set default error if field value is less than min', async () => { + const [{ result }] = renderUseIntegerField({ min: 0 }); + + act(() => { + result.current.control.setValue(-1); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.minValue(0)); + }); + + act(() => { + result.current.control.setValue(0); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if field value is more than max', async () => { + const [{ result }] = renderUseIntegerField({ max: 0 }); + + act(() => { + result.current.control.setValue(1); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.maxValue(0)); + }); + + act(() => { + result.current.control.setValue(0); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error in case of conversion error and clear it afterwards', async () => { + const [{ result }] = renderUseIntegerField({ + i18n: { + invalidInput: 'custom', + }, + }); + + act(() => { + result.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.onTextChange('a0'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.onTextChange('0'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result }] = renderUseIntegerField({ + required: true, + i18n: { + required: 'custom', + }, + }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field value is less than min', async () => { + const minValue = jest.fn(() => 'custom'); + + const [{ result }] = renderUseIntegerField({ + min: 0, + i18n: { + minValue, + }, + }); + + act(() => { + result.current.control.setValue(-1); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + expect(minValue).toBeCalledWith(0); + }); + }); + + it('Should set custom error if field value is more than max', async () => { + const maxValue = jest.fn(() => 'custom'); + + const [{ result }] = renderUseIntegerField({ + max: 0, + i18n: { + maxValue, + }, + }); + + act(() => { + result.current.control.setValue(1); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + expect(maxValue).toBeCalledWith(0); + }); + }); + + it('Should be able to format integer differently', () => { + const format = jest.fn(() => 'custom'); + const initialValue = 42; + const [{ result }] = renderUseIntegerField({ format, initialValue }); + + expect(result.current.text).toBe('custom'); + expect(format).toBeCalledWith(initialValue); + }); + + it('Should call custom parse function', async () => { + const parse = jest.fn(); + + const [{ result }] = renderUseIntegerField({ parse }); + + await act(() => { + result.current.onTextChange('0'); + }); + + await waitFor(() => { + expect(parse).toBeCalledWith('0'); + }); + }); +});