From 6fa257ad7bf3dd2f13217dd7c2a2624217ed3003 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 15:34:22 +0300 Subject: [PATCH 1/9] Created useDecimalField --- .gitignore | 5 +- .vscode/launch.json | 4 +- .vscode/launch.template.json | 15 ++ packages/x/src/useDecimalField.ts | 142 ++++++++++ packages/x/tests/useDecimalField.test.tsx | 312 ++++++++++++++++++++++ 5 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.template.json create mode 100644 packages/x/src/useDecimalField.ts create mode 100644 packages/x/tests/useDecimalField.test.tsx 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/useDecimalField.ts b/packages/x/src/useDecimalField.ts new file mode 100644 index 00000000..aaeb05e6 --- /dev/null +++ b/packages/x/src/useDecimalField.ts @@ -0,0 +1,142 @@ +import { useCallback } from 'react'; +import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; + +import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; + +const DECIMAL_REGEX = /^\d*\.?\d*$/; + +export const defaultLocales: Intl.LocalesArgument = 'EN'; + +export const defaultFormatOptions: Intl.NumberFormatOptions = { + minimumFractionDigits: 1, + maximumFractionDigits: 2, +}; + +const formatDecimal = ( + value: number | null | undefined, + locales?: Intl.LocalesArgument, + options?: Intl.NumberFormatOptions, +) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return ''; + } + + return value.toLocaleString(locales, options); +}; + +export type DecimalFieldErrorMessages = { + invalidInput: string; + required: string; + lessThanMinValue: (min: number, locales?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) => string; + moreThanMaxValue: (max: number, locales?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) => string; +}; + +export const defaultErrorMessages: DecimalFieldErrorMessages = { + invalidInput: 'Must be decimal', + required: 'Field is required', + lessThanMinValue: (min, locales, options) => + `Value should not be less than ${formatDecimal(min, locales, options)}`, + moreThanMaxValue: (max, locales, options) => + `Value should not be more than ${formatDecimal(max, locales, options)}`, +}; + +export type DecimalFieldConfig = FieldConfig & { + required?: boolean; + min?: number; + max?: number; + + formatValue?: (value: number | null | undefined) => string; + errorMessages?: Partial; + + locales?: Intl.LocalesArgument; + formatOptions?: Intl.NumberFormatOptions; +}; + +export type DecimalFieldBag = ConverterFieldBag & {}; + +export const useDecimalField = ({ + name, + validator, + schema, + required, + min, + max, + formatValue, + errorMessages = defaultErrorMessages, + locales = defaultLocales, + formatOptions = defaultFormatOptions, +}: DecimalFieldConfig): DecimalFieldBag => { + const parseDecimal = useCallback( + (text: string) => { + text = text.trim(); + + if (text.length === 0) { + return null; + } + + const errorMessage = errorMessages.invalidInput ?? defaultErrorMessages.invalidInput; + + if (!DECIMAL_REGEX.test(text)) { + throw new ConversionError(errorMessage); + } + + const value = Number.parseFloat(text); + + if (Number.isNaN(value)) { + // "." is valid decimal number zero, however Number.parseFloat returns NaN + if (text === '.') { + return 0; + } + + throw new ConversionError(errorMessage); + } + + return value; + }, + [errorMessages.invalidInput], + ); + + const format = useCallback( + (value: number | null | undefined) => { + if (formatValue) { + return formatValue(value); + } + + return formatDecimal(value, locales, formatOptions); + }, + [formatOptions, formatValue, locales], + ); + + const decimalBag = useConverterField({ + parse: parseDecimal, + format, + name, + validator, + schema, + }); + + useFieldValidator({ + name, + validator: (value) => { + if (required && typeof value !== 'number') { + return errorMessages.required ?? defaultErrorMessages.required; + } + + if (typeof value !== 'number') { + return undefined; + } + + if (typeof min === 'number' && value < min) { + return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min); + } + + if (typeof max === 'number' && value > max) { + return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max); + } + + 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..b7e4ec67 --- /dev/null +++ b/packages/x/tests/useDecimalField.test.tsx @@ -0,0 +1,312 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { + DecimalFieldConfig, + defaultErrorMessages, + defaultFormatOptions, + defaultLocales, + useDecimalField, +} from '../src/useDecimalField'; + +type Config = Omit & { + initialValue?: number | null; +}; + +const renderUseDecimalField = (config: Config = {}) => { + const { initialValue = 0, ...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((0).toLocaleString(defaultLocales, defaultFormatOptions)); + 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(defaultErrorMessages.invalidInput); + }); + + await act(() => { + result.current.onTextChange('a0'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + }); + + await act(() => { + result.current.onTextChange('hello'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.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(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.'); + }); + + 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(defaultErrorMessages.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(defaultErrorMessages.lessThanMinValue(0.5)); + }); + }); + + 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(defaultErrorMessages.moreThanMaxValue(0.5)); + }); + }); + + it('Should set custom conversion error correctly', async () => { + const [{ result }] = renderUseDecimalField({ + errorMessages: { + 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'); + }); + + 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(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.'); + }); + + await waitFor(() => { + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result }] = renderUseDecimalField({ + required: true, + errorMessages: { 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, + errorMessages: { lessThanMinValue: () => '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, + errorMessages: { moreThanMaxValue: () => 'custom' }, + }); + + act(() => { + result.current.control.setValue(0.75); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should be able to format decimal differently', () => { + const formatValue = jest.fn(() => 'custom'); + const initialValue = 3.14; + const [{ result }] = renderUseDecimalField({ formatValue, initialValue }); + + expect(result.current.text).toBe('custom'); + expect(formatValue).toBeCalledWith(initialValue); + }); +}); From 2758e02826a7cef7cc7e8f43051eb78f07eefd04 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 15:35:22 +0300 Subject: [PATCH 2/9] Added changeset --- .changeset/healthy-comics-refuse.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/healthy-comics-refuse.md 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 From 9bbd99c728bac94ace26f7cc4778320919d7f631 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 15:49:30 +0300 Subject: [PATCH 3/9] Refactoring --- packages/x/src/useDecimalField.ts | 2 +- packages/x/tests/useDecimalField.test.tsx | 64 ++++------------------- 2 files changed, 10 insertions(+), 56 deletions(-) diff --git a/packages/x/src/useDecimalField.ts b/packages/x/src/useDecimalField.ts index aaeb05e6..5c4a0701 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -52,7 +52,7 @@ export type DecimalFieldConfig = FieldConfig & { formatOptions?: Intl.NumberFormatOptions; }; -export type DecimalFieldBag = ConverterFieldBag & {}; +export type DecimalFieldBag = ConverterFieldBag; export const useDecimalField = ({ name, diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index b7e4ec67..0648c433 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -131,6 +131,15 @@ describe('Decimal field', () => { 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 () => { @@ -199,61 +208,6 @@ describe('Decimal field', () => { await waitFor(() => { expect(result.current.meta.error?.$error).toBe('custom'); }); - - 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(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.'); - }); - - await waitFor(() => { - expect(result.current.value).toBe(0); - expect(result.current.meta.error?.$error).toBeUndefined(); - }); }); it('Should set custom error if field is required and empty', async () => { From 90279ab963742689e5a08b076fb3dcaf673d7c7d Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 16:06:35 +0300 Subject: [PATCH 4/9] Added possibility to customize parseDecimal function --- packages/x/src/useDecimalField.ts | 4 +++- packages/x/tests/useDecimalField.test.tsx | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/x/src/useDecimalField.ts b/packages/x/src/useDecimalField.ts index 5c4a0701..12ef172c 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -46,6 +46,7 @@ export type DecimalFieldConfig = FieldConfig & { max?: number; formatValue?: (value: number | null | undefined) => string; + parseDecimal?: (text: string) => number; errorMessages?: Partial; locales?: Intl.LocalesArgument; @@ -62,6 +63,7 @@ export const useDecimalField = ({ min, max, formatValue, + parseDecimal: parseDecimalProps, errorMessages = defaultErrorMessages, locales = defaultLocales, formatOptions = defaultFormatOptions, @@ -108,7 +110,7 @@ export const useDecimalField = ({ ); const decimalBag = useConverterField({ - parse: parseDecimal, + parse: parseDecimalProps ?? parseDecimal, format, name, validator, diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index 0648c433..996ad0cc 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -263,4 +263,18 @@ describe('Decimal field', () => { expect(result.current.text).toBe('custom'); expect(formatValue).toBeCalledWith(initialValue); }); + + it('Should call custom parseDecimal function', async () => { + const parseDecimal = jest.fn(); + + const [{ result }] = renderUseDecimalField({ parseDecimal }); + + await act(() => { + result.current.onTextChange('0.0'); + }); + + await waitFor(() => { + expect(parseDecimal).toBeCalledWith('0.0'); + }); + }); }); From 191422f506317c9e8f7daba527651a0948f53dcc Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 29 Aug 2023 17:55:15 +0300 Subject: [PATCH 5/9] Refactored useDecimalField --- packages/x/src/useDecimalField.ts | 60 ++++++++--------------- packages/x/tests/useDecimalField.test.tsx | 31 +++++++----- 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/packages/x/src/useDecimalField.ts b/packages/x/src/useDecimalField.ts index 12ef172c..8c89dff2 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -4,40 +4,28 @@ import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; const DECIMAL_REGEX = /^\d*\.?\d*$/; +export const defaultPrecision = 2; -export const defaultLocales: Intl.LocalesArgument = 'EN'; - -export const defaultFormatOptions: Intl.NumberFormatOptions = { - minimumFractionDigits: 1, - maximumFractionDigits: 2, -}; - -const formatDecimal = ( - value: number | null | undefined, - locales?: Intl.LocalesArgument, - options?: Intl.NumberFormatOptions, -) => { +export const defaultFormat = (value: number | null | undefined, precision: number) => { if (typeof value !== 'number' || !Number.isFinite(value)) { return ''; } - return value.toLocaleString(locales, options); + return value.toFixed(precision).toString(); }; export type DecimalFieldErrorMessages = { invalidInput: string; required: string; - lessThanMinValue: (min: number, locales?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) => string; - moreThanMaxValue: (max: number, locales?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) => string; + lessThanMinValue: (min: number, precision: number) => string; + moreThanMaxValue: (max: number, precision: number) => string; }; export const defaultErrorMessages: DecimalFieldErrorMessages = { invalidInput: 'Must be decimal', required: 'Field is required', - lessThanMinValue: (min, locales, options) => - `Value should not be less than ${formatDecimal(min, locales, options)}`, - moreThanMaxValue: (max, locales, options) => - `Value should not be more than ${formatDecimal(max, locales, options)}`, + lessThanMinValue: (min, precision) => `Value should not be less than ${defaultFormat(min, precision)}`, + moreThanMaxValue: (max, precision) => `Value should not be more than ${defaultFormat(max, precision)}`, }; export type DecimalFieldConfig = FieldConfig & { @@ -45,12 +33,11 @@ export type DecimalFieldConfig = FieldConfig & { min?: number; max?: number; - formatValue?: (value: number | null | undefined) => string; - parseDecimal?: (text: string) => number; + format?: (value: number | null | undefined, precision: number) => string; + parse?: (text: string) => number; errorMessages?: Partial; - locales?: Intl.LocalesArgument; - formatOptions?: Intl.NumberFormatOptions; + precision?: number; }; export type DecimalFieldBag = ConverterFieldBag; @@ -62,13 +49,12 @@ export const useDecimalField = ({ required, min, max, - formatValue, - parseDecimal: parseDecimalProps, + format, + parse, errorMessages = defaultErrorMessages, - locales = defaultLocales, - formatOptions = defaultFormatOptions, + precision = defaultPrecision, }: DecimalFieldConfig): DecimalFieldBag => { - const parseDecimal = useCallback( + const defaultParse = useCallback( (text: string) => { text = text.trim(); @@ -98,20 +84,16 @@ export const useDecimalField = ({ [errorMessages.invalidInput], ); - const format = useCallback( + const formatValue = useCallback( (value: number | null | undefined) => { - if (formatValue) { - return formatValue(value); - } - - return formatDecimal(value, locales, formatOptions); + return (format ?? defaultFormat)(value, precision); }, - [formatOptions, formatValue, locales], + [format, precision], ); const decimalBag = useConverterField({ - parse: parseDecimalProps ?? parseDecimal, - format, + parse: parse ?? defaultParse, + format: formatValue, name, validator, schema, @@ -129,11 +111,11 @@ export const useDecimalField = ({ } if (typeof min === 'number' && value < min) { - return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min); + return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min, precision); } if (typeof max === 'number' && value > max) { - return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max); + return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max, precision); } return undefined; diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index 996ad0cc..c8ade33e 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -5,8 +5,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { DecimalFieldConfig, defaultErrorMessages, - defaultFormatOptions, - defaultLocales, + defaultFormat, + defaultPrecision, useDecimalField, } from '../src/useDecimalField'; @@ -46,7 +46,7 @@ describe('Decimal field', () => { it('Should format initial value correctly', () => { const [{ result }] = renderUseDecimalField(); - expect(result.current.text).toBe((0).toLocaleString(defaultLocales, defaultFormatOptions)); + expect(result.current.text).toBe(defaultFormat(0, defaultPrecision)); expect(result.current.value).toBe(0); }); @@ -162,7 +162,9 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.lessThanMinValue(0.5)); + expect(result.current.meta.error?.$error).toBe( + defaultErrorMessages.lessThanMinValue(0.5, defaultPrecision), + ); }); }); @@ -174,7 +176,9 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.moreThanMaxValue(0.5)); + expect(result.current.meta.error?.$error).toBe( + defaultErrorMessages.moreThanMaxValue(0.5, defaultPrecision), + ); }); }); @@ -255,26 +259,27 @@ describe('Decimal field', () => { }); }); - it('Should be able to format decimal differently', () => { - const formatValue = jest.fn(() => '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({ formatValue, initialValue }); + const [{ result }] = renderUseDecimalField({ format, initialValue }); expect(result.current.text).toBe('custom'); - expect(formatValue).toBeCalledWith(initialValue); + expect(format.mock.calls[0][0]).toBe(initialValue); }); - it('Should call custom parseDecimal function', async () => { - const parseDecimal = jest.fn(); + it('Should call custom parse function', async () => { + const parse = jest.fn(); - const [{ result }] = renderUseDecimalField({ parseDecimal }); + const [{ result }] = renderUseDecimalField({ parse }); await act(() => { result.current.onTextChange('0.0'); }); await waitFor(() => { - expect(parseDecimal).toBeCalledWith('0.0'); + expect(parse).toBeCalledWith('0.0'); }); }); }); From 581c7ccfd46ea270c3302eeb7c772d93f7c5726b Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Wed, 30 Aug 2023 12:16:47 +0300 Subject: [PATCH 6/9] Refactored DecimalField custom errors API --- packages/x/src/useDecimalField.ts | 64 ++++++++++++++--------- packages/x/tests/useDecimalField.test.tsx | 50 +++++++++++------- 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/packages/x/src/useDecimalField.ts b/packages/x/src/useDecimalField.ts index 8c89dff2..7d6f725b 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; +import { isFunction, isNil } from 'lodash'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; @@ -14,28 +15,23 @@ export const defaultFormat = (value: number | null | undefined, precision: numbe return value.toFixed(precision).toString(); }; -export type DecimalFieldErrorMessages = { - invalidInput: string; - required: string; - lessThanMinValue: (min: number, precision: number) => string; - moreThanMaxValue: (max: number, precision: number) => string; -}; +export const defaultRequiredError = 'Field is required'; +export const defaultInvalidInputError = 'Must be decimal'; +export const defaultMinValueError = (min: number, precision: number) => + `Value should not be less than ${defaultFormat(min, precision)}`; +export const defaultMaxValueError = (max: number, precision: number) => + `Value should not be more than ${defaultFormat(max, precision)}`; -export const defaultErrorMessages: DecimalFieldErrorMessages = { - invalidInput: 'Must be decimal', - required: 'Field is required', - lessThanMinValue: (min, precision) => `Value should not be less than ${defaultFormat(min, precision)}`, - moreThanMaxValue: (max, precision) => `Value should not be more than ${defaultFormat(max, precision)}`, -}; +export type ErrorTuple = [value: T, message: string | ((value: T) => string)]; export type DecimalFieldConfig = FieldConfig & { - required?: boolean; - min?: number; - max?: number; + required?: boolean | string; + invalidInput?: string; + min?: number | ErrorTuple; + max?: number | ErrorTuple; format?: (value: number | null | undefined, precision: number) => string; parse?: (text: string) => number; - errorMessages?: Partial; precision?: number; }; @@ -47,11 +43,11 @@ export const useDecimalField = ({ validator, schema, required, + invalidInput, min, max, format, parse, - errorMessages = defaultErrorMessages, precision = defaultPrecision, }: DecimalFieldConfig): DecimalFieldBag => { const defaultParse = useCallback( @@ -62,10 +58,10 @@ export const useDecimalField = ({ return null; } - const errorMessage = errorMessages.invalidInput ?? defaultErrorMessages.invalidInput; + const parseError = invalidInput ?? defaultInvalidInputError; if (!DECIMAL_REGEX.test(text)) { - throw new ConversionError(errorMessage); + throw new ConversionError(parseError); } const value = Number.parseFloat(text); @@ -76,12 +72,12 @@ export const useDecimalField = ({ return 0; } - throw new ConversionError(errorMessage); + throw new ConversionError(parseError); } return value; }, - [errorMessages.invalidInput], + [invalidInput], ); const formatValue = useCallback( @@ -103,19 +99,35 @@ export const useDecimalField = ({ name, validator: (value) => { if (required && typeof value !== 'number') { - return errorMessages.required ?? defaultErrorMessages.required; + return required === true ? defaultRequiredError : required; } if (typeof value !== 'number') { return undefined; } - if (typeof min === 'number' && value < min) { - return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min, precision); + if (!isNil(min)) { + if (Array.isArray(min)) { + const [minValue, message] = min; + + if (value < minValue) { + return isFunction(message) ? message(value) : message; + } + } else if (value < min) { + return defaultMinValueError(min, precision); + } } - if (typeof max === 'number' && value > max) { - return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max, precision); + if (!isNil(max)) { + if (Array.isArray(max)) { + const [maxValue, message] = max; + + if (value > maxValue) { + return isFunction(message) ? message(value) : message; + } + } else if (value > max) { + return defaultMaxValueError(max, precision); + } } return undefined; diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index c8ade33e..817acc7b 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -4,9 +4,12 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { DecimalFieldConfig, - defaultErrorMessages, defaultFormat, + defaultInvalidInputError, + defaultMaxValueError, + defaultMinValueError, defaultPrecision, + defaultRequiredError, useDecimalField, } from '../src/useDecimalField'; @@ -58,7 +61,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); await act(() => { @@ -66,7 +69,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); await act(() => { @@ -74,7 +77,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); await act(() => { @@ -150,7 +153,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.required); + expect(result.current.meta.error?.$error).toBe(defaultRequiredError); }); }); @@ -162,9 +165,15 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe( - defaultErrorMessages.lessThanMinValue(0.5, defaultPrecision), - ); + expect(result.current.meta.error?.$error).toBe(defaultMinValueError(0.5, defaultPrecision)); + }); + + act(() => { + result.current.control.setValue(0.5); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); @@ -176,17 +185,21 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe( - defaultErrorMessages.moreThanMaxValue(0.5, defaultPrecision), - ); + expect(result.current.meta.error?.$error).toBe(defaultMaxValueError(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({ - errorMessages: { - invalidInput: 'custom', - }, + invalidInput: 'custom', }); await act(() => { @@ -216,8 +229,7 @@ describe('Decimal field', () => { it('Should set custom error if field is required and empty', async () => { const [{ result }] = renderUseDecimalField({ - required: true, - errorMessages: { required: 'custom' }, + required: 'custom', }); act(() => { @@ -231,8 +243,7 @@ describe('Decimal field', () => { it('Should set custom error if field value is less than min', async () => { const [{ result }] = renderUseDecimalField({ - min: 0.5, - errorMessages: { lessThanMinValue: () => 'custom' }, + min: [0.5, 'custom'], }); act(() => { @@ -246,8 +257,7 @@ describe('Decimal field', () => { it('Should set custom error if field value is more than max', async () => { const [{ result }] = renderUseDecimalField({ - max: 0.5, - errorMessages: { moreThanMaxValue: () => 'custom' }, + max: [0.5, 'custom'], }); act(() => { From a0fa8d236e6aa60a9a8002b47a0e721a773c46e8 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 16:47:05 +0300 Subject: [PATCH 7/9] Extracted DecimalFieldI18nContext --- packages/x/src/DecimalFieldI18n.tsx | 36 ++++++++++++ packages/x/src/useDecimalField.ts | 70 +++++------------------ packages/x/tests/useDecimalField.test.tsx | 55 +++++++++++------- 3 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 packages/x/src/DecimalFieldI18n.tsx diff --git a/packages/x/src/DecimalFieldI18n.tsx b/packages/x/src/DecimalFieldI18n.tsx new file mode 100644 index 00000000..f08f59d1 --- /dev/null +++ b/packages/x/src/DecimalFieldI18n.tsx @@ -0,0 +1,36 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +export const defaultFormat = (value: number | null | undefined, precision: number) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return ''; + } + + return value.toFixed(precision).toString(); +}; + +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 ${defaultFormat(min, precision)}`, + maxValue: (max: number, precision: number) => `Value should not be greater than ${defaultFormat(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/useDecimalField.ts b/packages/x/src/useDecimalField.ts index 7d6f725b..7ac9a252 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -1,34 +1,16 @@ -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; -import { isFunction, isNil } from 'lodash'; +import { DecimalFieldI18nContext, defaultFormat } from './DecimalFieldI18n'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; const DECIMAL_REGEX = /^\d*\.?\d*$/; export const defaultPrecision = 2; -export const defaultFormat = (value: number | null | undefined, precision: number) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return ''; - } - - return value.toFixed(precision).toString(); -}; - -export const defaultRequiredError = 'Field is required'; -export const defaultInvalidInputError = 'Must be decimal'; -export const defaultMinValueError = (min: number, precision: number) => - `Value should not be less than ${defaultFormat(min, precision)}`; -export const defaultMaxValueError = (max: number, precision: number) => - `Value should not be more than ${defaultFormat(max, precision)}`; - -export type ErrorTuple = [value: T, message: string | ((value: T) => string)]; - export type DecimalFieldConfig = FieldConfig & { - required?: boolean | string; - invalidInput?: string; - min?: number | ErrorTuple; - max?: number | ErrorTuple; + required?: boolean; + min?: number; + max?: number; format?: (value: number | null | undefined, precision: number) => string; parse?: (text: string) => number; @@ -43,13 +25,14 @@ export const useDecimalField = ({ validator, schema, required, - invalidInput, min, max, format, parse, precision = defaultPrecision, }: DecimalFieldConfig): DecimalFieldBag => { + const i18n = useContext(DecimalFieldI18nContext); + const defaultParse = useCallback( (text: string) => { text = text.trim(); @@ -58,26 +41,19 @@ export const useDecimalField = ({ return null; } - const parseError = invalidInput ?? defaultInvalidInputError; - if (!DECIMAL_REGEX.test(text)) { - throw new ConversionError(parseError); + throw new ConversionError(i18n.invalidInput); } const value = Number.parseFloat(text); if (Number.isNaN(value)) { - // "." is valid decimal number zero, however Number.parseFloat returns NaN - if (text === '.') { - return 0; - } - - throw new ConversionError(parseError); + throw new ConversionError(i18n.invalidInput); } return value; }, - [invalidInput], + [i18n.invalidInput], ); const formatValue = useCallback( @@ -99,35 +75,19 @@ export const useDecimalField = ({ name, validator: (value) => { if (required && typeof value !== 'number') { - return required === true ? defaultRequiredError : required; + return i18n.required; } if (typeof value !== 'number') { return undefined; } - if (!isNil(min)) { - if (Array.isArray(min)) { - const [minValue, message] = min; - - if (value < minValue) { - return isFunction(message) ? message(value) : message; - } - } else if (value < min) { - return defaultMinValueError(min, precision); - } + if (typeof min === 'number' && value < min) { + return i18n.minValue(min, precision); } - if (!isNil(max)) { - if (Array.isArray(max)) { - const [maxValue, message] = max; - - if (value > maxValue) { - return isFunction(message) ? message(value) : message; - } - } else if (value > max) { - return defaultMaxValueError(max, precision); - } + if (typeof max === 'number' && value > max) { + return i18n.maxValue(max, precision); } return undefined; diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index 817acc7b..df288b19 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -3,22 +3,20 @@ import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; import { - DecimalFieldConfig, + DecimalFieldI18n, + DecimalFieldI18nContextProvider, + defaultDecimalFieldI18n, defaultFormat, - defaultInvalidInputError, - defaultMaxValueError, - defaultMinValueError, - defaultPrecision, - defaultRequiredError, - useDecimalField, -} from '../src/useDecimalField'; +} from '../src/DecimalFieldI18n'; +import { DecimalFieldConfig, defaultPrecision, useDecimalField } from '../src/useDecimalField'; type Config = Omit & { initialValue?: number | null; + i18n?: Partial; }; const renderUseDecimalField = (config: Config = {}) => { - const { initialValue = 0, ...initialProps } = config; + const { initialValue = 0, i18n, ...initialProps } = config; const formBag = renderHook(() => useForm({ @@ -36,7 +34,9 @@ const renderUseDecimalField = (config: Config = {}) => { }), { wrapper: ({ children }) => ( - {children} + + {children} + ), initialProps, }, @@ -61,7 +61,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); }); await act(() => { @@ -69,7 +69,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); }); await act(() => { @@ -77,7 +77,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); }); await act(() => { @@ -113,8 +113,8 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.value).toBe(0); - expect(result.current.meta.error?.$error).toBeUndefined(); + expect(result.current.value).toBe(null); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput); }); await act(() => { @@ -153,7 +153,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultRequiredError); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.required); }); }); @@ -165,7 +165,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultMinValueError(0.5, defaultPrecision)); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.minValue(0.5, defaultPrecision)); }); act(() => { @@ -185,7 +185,7 @@ describe('Decimal field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultMaxValueError(0.5, defaultPrecision)); + expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.maxValue(0.5, defaultPrecision)); }); act(() => { @@ -199,7 +199,9 @@ describe('Decimal field', () => { it('Should set custom conversion error correctly', async () => { const [{ result }] = renderUseDecimalField({ - invalidInput: 'custom', + i18n: { + invalidInput: 'custom', + }, }); await act(() => { @@ -229,7 +231,10 @@ describe('Decimal field', () => { it('Should set custom error if field is required and empty', async () => { const [{ result }] = renderUseDecimalField({ - required: 'custom', + required: true, + i18n: { + required: 'custom', + }, }); act(() => { @@ -243,7 +248,10 @@ describe('Decimal field', () => { it('Should set custom error if field value is less than min', async () => { const [{ result }] = renderUseDecimalField({ - min: [0.5, 'custom'], + min: 0.5, + i18n: { + minValue: () => 'custom', + }, }); act(() => { @@ -257,7 +265,10 @@ describe('Decimal field', () => { it('Should set custom error if field value is more than max', async () => { const [{ result }] = renderUseDecimalField({ - max: [0.5, 'custom'], + max: 0.5, + i18n: { + maxValue: () => 'custom', + }, }); act(() => { From 88aac09c46ebc903681da6be51693cdbc694ebae Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 16:49:40 +0300 Subject: [PATCH 8/9] Extracted formatDecimal function --- packages/x/src/DecimalFieldI18n.tsx | 12 +++--------- packages/x/src/formatDecimal.ts | 7 +++++++ packages/x/src/useDecimalField.ts | 5 +++-- packages/x/tests/useDecimalField.test.tsx | 10 +++------- 4 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 packages/x/src/formatDecimal.ts diff --git a/packages/x/src/DecimalFieldI18n.tsx b/packages/x/src/DecimalFieldI18n.tsx index f08f59d1..1fae42f0 100644 --- a/packages/x/src/DecimalFieldI18n.tsx +++ b/packages/x/src/DecimalFieldI18n.tsx @@ -1,13 +1,7 @@ import React, { createContext, PropsWithChildren } from 'react'; import merge from 'lodash/merge'; -export const defaultFormat = (value: number | null | undefined, precision: number) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return ''; - } - - return value.toFixed(precision).toString(); -}; +import { formatDecimal } from './formatDecimal'; export type DecimalFieldI18n = { required: string; @@ -19,8 +13,8 @@ export type DecimalFieldI18n = { export const defaultDecimalFieldI18n: DecimalFieldI18n = { required: 'Field is required', invalidInput: 'Must be decimal', - minValue: (min: number, precision: number) => `Value should not be less than ${defaultFormat(min, precision)}`, - maxValue: (max: number, precision: number) => `Value should not be greater than ${defaultFormat(max, precision)}`, + 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); 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/useDecimalField.ts b/packages/x/src/useDecimalField.ts index 7ac9a252..98f42961 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -1,7 +1,8 @@ import { useCallback, useContext } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; -import { DecimalFieldI18nContext, defaultFormat } from './DecimalFieldI18n'; +import { DecimalFieldI18nContext } from './DecimalFieldI18n'; +import { formatDecimal } from './formatDecimal'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; const DECIMAL_REGEX = /^\d*\.?\d*$/; @@ -58,7 +59,7 @@ export const useDecimalField = ({ const formatValue = useCallback( (value: number | null | undefined) => { - return (format ?? defaultFormat)(value, precision); + return (format ?? formatDecimal)(value, precision); }, [format, precision], ); diff --git a/packages/x/tests/useDecimalField.test.tsx b/packages/x/tests/useDecimalField.test.tsx index df288b19..4235c366 100644 --- a/packages/x/tests/useDecimalField.test.tsx +++ b/packages/x/tests/useDecimalField.test.tsx @@ -2,12 +2,8 @@ import React from 'react'; import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { - DecimalFieldI18n, - DecimalFieldI18nContextProvider, - defaultDecimalFieldI18n, - defaultFormat, -} from '../src/DecimalFieldI18n'; +import { DecimalFieldI18n, DecimalFieldI18nContextProvider, defaultDecimalFieldI18n } from '../src/DecimalFieldI18n'; +import { formatDecimal } from '../src/formatDecimal'; import { DecimalFieldConfig, defaultPrecision, useDecimalField } from '../src/useDecimalField'; type Config = Omit & { @@ -49,7 +45,7 @@ describe('Decimal field', () => { it('Should format initial value correctly', () => { const [{ result }] = renderUseDecimalField(); - expect(result.current.text).toBe(defaultFormat(0, defaultPrecision)); + expect(result.current.text).toBe(formatDecimal(0, defaultPrecision)); expect(result.current.value).toBe(0); }); From dec9992ebf336b3daea17317d65147214655841d Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 18:05:58 +0300 Subject: [PATCH 9/9] Refactoring --- packages/x/src/useConverterField.ts | 6 ++++-- packages/x/src/useDecimalField.ts | 33 +++++++++++++++++------------ 2 files changed, 23 insertions(+), 16 deletions(-) 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 index 98f42961..35864f41 100644 --- a/packages/x/src/useDecimalField.ts +++ b/packages/x/src/useDecimalField.ts @@ -3,7 +3,7 @@ import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; import { DecimalFieldI18nContext } from './DecimalFieldI18n'; import { formatDecimal } from './formatDecimal'; -import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; +import { ConversionError, ConverterFieldBag, useConverterField, ValueConverter } from './useConverterField'; const DECIMAL_REGEX = /^\d*\.?\d*$/; export const defaultPrecision = 2; @@ -13,11 +13,8 @@ export type DecimalFieldConfig = FieldConfig & { min?: number; max?: number; - format?: (value: number | null | undefined, precision: number) => string; - parse?: (text: string) => number; - precision?: number; -}; +} & Partial>; export type DecimalFieldBag = ConverterFieldBag; @@ -28,16 +25,20 @@ export const useDecimalField = ({ required, min, max, - format, - parse, + format: customFormat, + parse: customParse, precision = defaultPrecision, }: DecimalFieldConfig): DecimalFieldBag => { const i18n = useContext(DecimalFieldI18nContext); - const defaultParse = useCallback( + const parse = useCallback( (text: string) => { text = text.trim(); + if (customParse) { + return customParse(text); + } + if (text.length === 0) { return null; } @@ -54,19 +55,23 @@ export const useDecimalField = ({ return value; }, - [i18n.invalidInput], + [customParse, i18n.invalidInput], ); - const formatValue = useCallback( + const format = useCallback( (value: number | null | undefined) => { - return (format ?? formatDecimal)(value, precision); + if (customFormat) { + return customFormat(value); + } + + return formatDecimal(value, precision); }, - [format, precision], + [customFormat, precision], ); const decimalBag = useConverterField({ - parse: parse ?? defaultParse, - format: formatValue, + parse, + format, name, validator, schema,