diff --git a/.changeset/healthy-comics-refuse.md b/.changeset/healthy-comics-refuse.md new file mode 100644 index 00000000..41f993ad --- /dev/null +++ b/.changeset/healthy-comics-refuse.md @@ -0,0 +1,5 @@ +--- +'@reactive-forms/x': minor +--- + +Created useDecimalField hook diff --git a/.gitignore b/.gitignore index 7c73a981..e6e1e2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ yarn-error.log* scripts/*.mjs coverage -**/.turbo \ No newline at end of file +**/.turbo + +# vscode +launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json index b1f33dea..b6f2cad4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,8 +5,8 @@ "name": "Debug Core Tests", "type": "node", "request": "launch", - "runtimeArgs": ["--inspect-brk", "${workspaceFolder}/node_modules/aqu/dist/aqu.js", "test", "--runInBand"], - "cwd": "${workspaceFolder}/packages/core", + "runtimeArgs": ["--inspect-brk", ".\\node_modules\\jest\\bin\\jest.js", "--watch", "useDecimalField"], + "cwd": "${workspaceFolder}/packages/x", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "port": 9229 diff --git a/.vscode/launch.template.json b/.vscode/launch.template.json new file mode 100644 index 00000000..e3c68b49 --- /dev/null +++ b/.vscode/launch.template.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Core Tests", + "type": "node", + "request": "launch", + "runtimeArgs": ["--inspect-brk", ".\\node_modules\\jest\\bin\\jest.js", "--watch"], + "cwd": "${workspaceFolder}/packages/core", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "port": 9229 + } + ] +} diff --git a/packages/x/src/DecimalFieldI18n.tsx b/packages/x/src/DecimalFieldI18n.tsx new file mode 100644 index 00000000..1fae42f0 --- /dev/null +++ b/packages/x/src/DecimalFieldI18n.tsx @@ -0,0 +1,30 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +import { formatDecimal } from './formatDecimal'; + +export type DecimalFieldI18n = { + required: string; + invalidInput: string; + minValue: (value: number, precision: number) => string; + maxValue: (value: number, precision: number) => string; +}; + +export const defaultDecimalFieldI18n: DecimalFieldI18n = { + required: 'Field is required', + invalidInput: 'Must be decimal', + minValue: (min: number, precision: number) => `Value should not be less than ${formatDecimal(min, precision)}`, + maxValue: (max: number, precision: number) => `Value should not be greater than ${formatDecimal(max, precision)}`, +}; + +export const DecimalFieldI18nContext = createContext(defaultDecimalFieldI18n); + +export type DecimalFieldI18nContextProviderProps = PropsWithChildren<{ i18n?: Partial }>; + +export const DecimalFieldI18nContextProvider = ({ i18n, children }: DecimalFieldI18nContextProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/x/src/formatDecimal.ts b/packages/x/src/formatDecimal.ts new file mode 100644 index 00000000..8da992d3 --- /dev/null +++ b/packages/x/src/formatDecimal.ts @@ -0,0 +1,7 @@ +export const formatDecimal = (value: number | null | undefined, precision: number) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return ''; + } + + return value.toFixed(precision).toString(); +}; 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/useDecimalField.ts b/packages/x/src/useDecimalField.ts new file mode 100644 index 00000000..35864f41 --- /dev/null +++ b/packages/x/src/useDecimalField.ts @@ -0,0 +1,104 @@ +import { useCallback, useContext } from 'react'; +import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; + +import { DecimalFieldI18nContext } from './DecimalFieldI18n'; +import { formatDecimal } from './formatDecimal'; +import { ConversionError, ConverterFieldBag, useConverterField, ValueConverter } from './useConverterField'; + +const DECIMAL_REGEX = /^\d*\.?\d*$/; +export const defaultPrecision = 2; + +export type DecimalFieldConfig = FieldConfig & { + required?: boolean; + min?: number; + max?: number; + + precision?: number; +} & Partial>; + +export type DecimalFieldBag = ConverterFieldBag; + +export const useDecimalField = ({ + name, + validator, + schema, + required, + min, + max, + format: customFormat, + parse: customParse, + precision = defaultPrecision, +}: DecimalFieldConfig): DecimalFieldBag => { + const i18n = useContext(DecimalFieldI18nContext); + + const parse = useCallback( + (text: string) => { + text = text.trim(); + + if (customParse) { + return customParse(text); + } + + if (text.length === 0) { + return null; + } + + if (!DECIMAL_REGEX.test(text)) { + throw new ConversionError(i18n.invalidInput); + } + + const value = Number.parseFloat(text); + + if (Number.isNaN(value)) { + throw new ConversionError(i18n.invalidInput); + } + + return value; + }, + [customParse, i18n.invalidInput], + ); + + const format = useCallback( + (value: number | null | undefined) => { + if (customFormat) { + return customFormat(value); + } + + return formatDecimal(value, precision); + }, + [customFormat, precision], + ); + + const decimalBag = 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, precision); + } + + if (typeof max === 'number' && value > max) { + return i18n.maxValue(max, precision); + } + + return undefined; + }, + }); + + return decimalBag; +}; diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx new file mode 100644 index 00000000..4235c366 --- /dev/null +++ b/packages/x/tests/useDecimalField.test.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { DecimalFieldI18n, DecimalFieldI18nContextProvider, defaultDecimalFieldI18n } from '../src/DecimalFieldI18n'; +import { formatDecimal } from '../src/formatDecimal'; +import { DecimalFieldConfig, defaultPrecision, useDecimalField } from '../src/useDecimalField'; + +type Config = Omit & { + initialValue?: number | null; + i18n?: Partial; +}; + +const renderUseDecimalField = (config: Config = {}) => { + const { initialValue = 0, i18n, ...initialProps } = config; + + const formBag = renderHook(() => + useForm({ + initialValues: { + test: initialValue, + }, + }), + ); + + const decimalFieldBag = renderHook( + (props: Omit) => + useDecimalField({ + name: formBag.result.current.paths.test, + ...props, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps, + }, + ); + + return [decimalFieldBag, formBag] as const; +}; + +describe('Decimal field', () => { + it('Should format initial value correctly', () => { + const [{ result }] = renderUseDecimalField(); + + expect(result.current.text).toBe(formatDecimal(0, defaultPrecision)); + expect(result.current.value).toBe(0); + }); + + it('Should set default conversion error correctly', async () => { + const [{ result }] = renderUseDecimalField(); + + await act(() => { + result.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); + }); + + await act(() => { + result.current.onTextChange('a0'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); + }); + + await act(() => { + result.current.onTextChange('hello'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); + }); + + await act(() => { + result.current.onTextChange('0'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + await act(() => { + result.current.onTextChange(''); + }); + + await waitFor(() => { + expect(result.current.value).toBe(null); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + await act(() => { + result.current.onTextChange(' '); + }); + + await waitFor(() => { + expect(result.current.value).toBe(null); + expect(result.current.text).toBe(' '); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + await act(() => { + result.current.onTextChange('.'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(null); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); + }); + + await act(() => { + result.current.onTextChange('.0'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + await act(() => { + result.current.onTextChange('0.'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + await act(() => { + result.current.onTextChange('0.0'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if field is required and empty', async () => { + const [{ result }] = renderUseDecimalField({ required: true }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.required); + }); + }); + + it('Should set default error if field value is less than min', async () => { + const [{ result }] = renderUseDecimalField({ min: 0.5 }); + + act(() => { + result.current.control.setValue(0.25); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.minValue(0.5, defaultPrecision)); + }); + + act(() => { + result.current.control.setValue(0.5); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if field value is more than max', async () => { + const [{ result }] = renderUseDecimalField({ max: 0.5 }); + + act(() => { + result.current.control.setValue(0.75); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.maxValue(0.5, defaultPrecision)); + }); + + act(() => { + result.current.control.setValue(0.5); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom conversion error correctly', async () => { + const [{ result }] = renderUseDecimalField({ + i18n: { + invalidInput: 'custom', + }, + }); + + await act(() => { + result.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + await act(() => { + result.current.onTextChange('a0'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + await act(() => { + result.current.onTextChange('hello'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result }] = renderUseDecimalField({ + 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 [{ result }] = renderUseDecimalField({ + min: 0.5, + i18n: { + minValue: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue(0.25); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field value is more than max', async () => { + const [{ result }] = renderUseDecimalField({ + max: 0.5, + i18n: { + maxValue: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue(0.75); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should call custom format function', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const format = jest.fn((_value: number | null | undefined) => 'custom'); + const initialValue = 3.14; + const [{ result }] = renderUseDecimalField({ format, initialValue }); + + expect(result.current.text).toBe('custom'); + expect(format.mock.calls[0][0]).toBe(initialValue); + }); + + it('Should call custom parse function', async () => { + const parse = jest.fn(); + + const [{ result }] = renderUseDecimalField({ parse }); + + await act(() => { + result.current.onTextChange('0.0'); + }); + + await waitFor(() => { + expect(parse).toBeCalledWith('0.0'); + }); + }); +});