From 29116ffc17d4467c3873daa275de0ef50d910391 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 15:09:18 +0300 Subject: [PATCH 01/26] Created `useConverterField` hook --- packages/x/package.json | 10 ++- packages/x/src/useConverterField.ts | 65 ++++++++++++++++++ packages/x/tests/useConverterField.test.tsx | 74 +++++++++++++++++++++ packages/x/tsconfig.json | 2 +- pnpm-lock.yaml | 36 +++++++--- 5 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 packages/x/src/useConverterField.ts create mode 100644 packages/x/tests/useConverterField.test.tsx diff --git a/packages/x/package.json b/packages/x/package.json index 2bcccb22..9519a9a2 100644 --- a/packages/x/package.json +++ b/packages/x/package.json @@ -29,12 +29,16 @@ "@babel/core": "7.19.6", "@reactive-forms/core": "workspace:*", "@reactive-tools/eslint-config": "workspace:*", + "@testing-library/react": "13.4.0", "@types/jest": "26.0.24", + "@types/lodash": "4.14.161", "@types/react": "18.0.23", "aqu": "0.4.3", "jest": "29.2.2", "react": "18.2.0", "rimraf": "3.0.2", + "ts-jest": "29.0.3", + "tslib": "2.3.1", "typescript": "4.8.4" }, "peerDependencies": { @@ -44,5 +48,9 @@ "files": [ "dist" ], - "source": "src/index.ts" + "source": "src/index.ts", + "dependencies": { + "lodash": "4.17.21", + "pxth": "0.6.0" + } } diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts new file mode 100644 index 00000000..39099548 --- /dev/null +++ b/packages/x/src/useConverterField.ts @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { FieldConfig, FieldContext, FieldError, useField } from '@reactive-forms/core'; +import isObject from 'lodash/isObject'; + +export class ConversionError extends Error { + public constructor(errorMessage: string) { + super(errorMessage); + } +} + +export type ConverterFieldConfig = { + parse: (value: string) => T; // can throw + format: (value: T) => string; // cannot throw + + onChangeText?: (text: string) => void; +} & FieldConfig; + +export type ConverterFieldBag = { + text: string; + onTextChange: (text: string) => void; +} & FieldContext; + +export const useConverterField = ({ + parse, + format, + onChangeText, + ...fieldConfig +}: ConverterFieldConfig): ConverterFieldBag => { + const fieldBag = useField(fieldConfig); + + const { + value, + control: { setValue, setError }, + } = fieldBag; + + const [text, setText] = useState(() => format(value)); + + const tryConvert = (text: string) => { + try { + setValue(parse(text)); + // setHasConversionError(false); + } catch (error) { + if (isObject(error) && error instanceof ConversionError) { + // setHasConversionError(true); + setError({ + $error: error.message, + } as FieldError); + } else { + throw error; + } + } + }; + + const onTextChange = (newText: string) => { + setText(newText); + tryConvert(newText); + onChangeText?.(newText); + }; + + return { + text, + onTextChange, + ...fieldBag, + }; +}; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx new file mode 100644 index 00000000..1172efb8 --- /dev/null +++ b/packages/x/tests/useConverterField.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook } from '@testing-library/react'; + +import { ConversionError, useConverterField } from '../src/useConverterField'; + +const renderUseConverterField = () => { + const { result: formBag } = renderHook(() => + useForm({ + initialValues: { + test: 0, + }, + }), + ); + + const { result: converterFieldBag } = renderHook( + () => + useConverterField({ + parse: (text) => { + const parsingResult = Number.parseInt(text); + + if (Number.isNaN(parsingResult)) { + throw new ConversionError('hello'); + } + + return parsingResult; + }, + format: (value) => String(value), + name: formBag.current.paths.test, + }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + + return { + formBag, + converterFieldBag, + }; +}; + +describe('Converter field', () => { + it('Should update field with valid value', async () => { + const { converterFieldBag } = renderUseConverterField(); + const { onTextChange } = converterFieldBag.current; + + expect(converterFieldBag.current.value).toBe(0); + expect(converterFieldBag.current.text).toBe('0'); + + await act(async () => { + await onTextChange('1'); + }); + + expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.text).toBe('1'); + }); + + it('Should set an error if conversion fails', async () => { + const { converterFieldBag } = renderUseConverterField(); + const { onTextChange } = converterFieldBag.current; + + await act(async () => { + await onTextChange('a'); + }); + + expect(converterFieldBag.current.meta.error?.$error).toBe('hello'); + expect(converterFieldBag.current.value).toBe(0); + expect(converterFieldBag.current.text).toBe('a'); + }); + + it('Should update text when form value changes', async () => {}); +}); diff --git a/packages/x/tsconfig.json b/packages/x/tsconfig.json index f7dfac1c..a027e04d 100644 --- a/packages/x/tsconfig.json +++ b/packages/x/tsconfig.json @@ -21,5 +21,5 @@ "strict": true, "importHelpers": true }, - "include": ["src", "types"] + "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f428aa2b..0aede1f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,23 +157,36 @@ importers: '@babel/core': 7.19.6 '@reactive-forms/core': workspace:* '@reactive-tools/eslint-config': workspace:* + '@testing-library/react': 13.4.0 '@types/jest': 26.0.24 + '@types/lodash': 4.14.161 '@types/react': 18.0.23 aqu: 0.4.3 jest: 29.2.2 + lodash: 4.17.21 + pxth: 0.6.0 react: 18.2.0 rimraf: 3.0.2 + ts-jest: 29.0.3 + tslib: 2.3.1 typescript: 4.8.4 + dependencies: + lodash: 4.17.21 + pxth: 0.6.0 devDependencies: '@babel/core': 7.19.6 '@reactive-forms/core': link:../core '@reactive-tools/eslint-config': link:../../tools/eslint-config + '@testing-library/react': 13.4.0_react@18.2.0 '@types/jest': 26.0.24 + '@types/lodash': 4.14.161 '@types/react': 18.0.23 aqu: 0.4.3_@babel+core@7.19.6 jest: 29.2.2 react: 18.2.0 rimraf: 3.0.2 + ts-jest: 29.0.3_fxe5mizohawh6cjruma5lyyrna + tslib: 2.3.1 typescript: 4.8.4 publishDirectory: prepared-package @@ -3228,7 +3241,7 @@ packages: engines: {node: '>=12'} dependencies: '@babel/code-frame': 7.18.6 - '@babel/runtime': 7.19.4 + '@babel/runtime': 7.20.7 '@types/aria-query': 4.2.2 aria-query: 5.1.1 chalk: 4.1.2 @@ -3251,6 +3264,19 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: true + /@testing-library/react/13.4.0_react@18.2.0: + resolution: {integrity: sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==} + engines: {node: '>=12'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@babel/runtime': 7.20.7 + '@testing-library/dom': 8.19.0 + '@types/react-dom': 18.0.7 + react: 18.2.0 + dev: true + /@tootallnate/once/2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -9211,7 +9237,7 @@ packages: '@babel/core': 7.19.6 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.2.2_@types+node@18.11.18 + jest: 29.2.2 jest-util: 29.2.1 json5: 2.2.1 lodash.memoize: 4.1.2 @@ -9433,12 +9459,6 @@ packages: engines: {node: '>=4.2.0'} hasBin: true - /typescript/5.1.3: - resolution: {integrity: sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - /unbox-primitive/1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: From 3b753e698135bfbe5b760547688752f9ebc8292a Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 16:30:22 +0300 Subject: [PATCH 02/26] Synchronize form value with text state --- packages/x/src/index.ts | 1 + packages/x/src/useConverterField.ts | 25 ++++++++++++---- packages/x/tests/useConverterField.test.tsx | 33 ++++++++++++++++++++- packages/x/tsconfig.json | 2 +- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/x/src/index.ts b/packages/x/src/index.ts index 1110b645..8eb012ea 100644 --- a/packages/x/src/index.ts +++ b/packages/x/src/index.ts @@ -1 +1,2 @@ export * from './plugin'; +export * from './useConverterField'; diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 39099548..a93cd237 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { FieldConfig, FieldContext, FieldError, useField } from '@reactive-forms/core'; import isObject from 'lodash/isObject'; @@ -9,8 +9,8 @@ export class ConversionError extends Error { } export type ConverterFieldConfig = { - parse: (value: string) => T; // can throw - format: (value: T) => string; // cannot throw + parse: (value: string) => T; + format: (value: T) => string; onChangeText?: (text: string) => void; } & FieldConfig; @@ -34,14 +34,18 @@ export const useConverterField = ({ } = fieldBag; const [text, setText] = useState(() => format(value)); + const textRef = useRef(text); + textRef.current = text; + + const [hasConversionError, setHasConversionError] = useState(false); const tryConvert = (text: string) => { try { setValue(parse(text)); - // setHasConversionError(false); + setHasConversionError(false); } catch (error) { if (isObject(error) && error instanceof ConversionError) { - // setHasConversionError(true); + setHasConversionError(true); setError({ $error: error.message, } as FieldError); @@ -52,11 +56,22 @@ export const useConverterField = ({ }; const onTextChange = (newText: string) => { + textRef.current = newText; setText(newText); tryConvert(newText); onChangeText?.(newText); }; + useEffect(() => { + if (hasConversionError) { + return; + } + + const formattedValue = format(value); + textRef.current = formattedValue; + setText(formattedValue); + }, [value, format, hasConversionError]); + return { text, onTextChange, diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 1172efb8..2f039e23 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -70,5 +70,36 @@ describe('Converter field', () => { expect(converterFieldBag.current.text).toBe('a'); }); - it('Should update text when form value changes', async () => {}); + it('Should update text when form value changes', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); + + const { paths } = formBag.current; + + await act(async () => { + await formBag.current.setFieldValue(paths.test, 1); + }); + + expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.text).toBe('1'); + }); + + it('should clear conversion error', async () => { + const { converterFieldBag } = renderUseConverterField(); + + const { onTextChange } = converterFieldBag.current; + + await act(async () => { + await onTextChange('a'); + }); + + expect(converterFieldBag.current.meta.error?.$error).toBe('hello'); + + await act(async () => { + await onTextChange('1'); + }); + + expect(converterFieldBag.current.meta.error?.$error).toBeUndefined(); + expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.text).toBe('1'); + }); }); diff --git a/packages/x/tsconfig.json b/packages/x/tsconfig.json index a027e04d..97f79bdb 100644 --- a/packages/x/tsconfig.json +++ b/packages/x/tsconfig.json @@ -21,5 +21,5 @@ "strict": true, "importHelpers": true }, - "include": ["src"] + "include": ["src", "tests"] } From d008ef83eaa788a037ee6ac516b9134eea51ad38 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 16:31:16 +0300 Subject: [PATCH 03/26] Created changeset --- .changeset/four-turkeys-kiss.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/four-turkeys-kiss.md diff --git a/.changeset/four-turkeys-kiss.md b/.changeset/four-turkeys-kiss.md new file mode 100644 index 00000000..a9693875 --- /dev/null +++ b/.changeset/four-turkeys-kiss.md @@ -0,0 +1,5 @@ +--- +'@reactive-forms/x': patch +--- + +Created useConverterField hook in @reactive-forms/x package From 95da53a2b1d1764be5531eff428ad42244092266 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 16:43:19 +0300 Subject: [PATCH 04/26] Added more tests --- packages/x/tests/useConverterField.test.tsx | 51 ++++++++++++++++----- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 2f039e23..aa61532c 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -4,7 +4,17 @@ import { act, renderHook } from '@testing-library/react'; import { ConversionError, useConverterField } from '../src/useConverterField'; -const renderUseConverterField = () => { +const defaultParse = (text: string) => { + const parsingResult = Number.parseInt(text); + + if (Number.isNaN(parsingResult)) { + throw new ConversionError('hello'); + } + + return parsingResult; +}; + +const renderUseConverterField = (parse: (value: string) => number = defaultParse) => { const { result: formBag } = renderHook(() => useForm({ initialValues: { @@ -16,15 +26,7 @@ const renderUseConverterField = () => { const { result: converterFieldBag } = renderHook( () => useConverterField({ - parse: (text) => { - const parsingResult = Number.parseInt(text); - - if (Number.isNaN(parsingResult)) { - throw new ConversionError('hello'); - } - - return parsingResult; - }, + parse, format: (value) => String(value), name: formBag.current.paths.test, }), @@ -83,7 +85,7 @@ describe('Converter field', () => { expect(converterFieldBag.current.text).toBe('1'); }); - it('should clear conversion error', async () => { + it('Should clear conversion error', async () => { const { converterFieldBag } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; @@ -102,4 +104,31 @@ describe('Converter field', () => { expect(converterFieldBag.current.value).toBe(1); expect(converterFieldBag.current.text).toBe('1'); }); + + it('Should rethrow an error in case it is not ConversionError', () => { + const { converterFieldBag } = renderUseConverterField(() => { + throw new Error('custom'); + }); + + act(() => { + expect(() => converterFieldBag.current.onTextChange('')).toThrow(); + }); + }); + + it('Should not update text if there are some conversion errors', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); + const { onTextChange } = converterFieldBag.current; + const { setFieldValue, paths } = formBag.current; + + await act(async () => { + await onTextChange('a'); + }); + + await act(async () => { + await setFieldValue(paths.test, 1); + }); + + expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.text).toBe('a'); + }); }); From da1df9bcc0a3ea29e2c908c9b4db975370654598 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 17:43:33 +0300 Subject: [PATCH 05/26] Added validator in ConverterField --- packages/x/src/useConverterField.ts | 22 +++++++++++++++++++-- packages/x/tests/useConverterField.test.tsx | 15 ++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index a93cd237..6746ae14 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { FieldConfig, FieldContext, FieldError, useField } from '@reactive-forms/core'; +import { FieldConfig, FieldContext, FieldError, useField, useFieldValidator } from '@reactive-forms/core'; import isObject from 'lodash/isObject'; export class ConversionError extends Error { @@ -41,7 +41,8 @@ export const useConverterField = ({ const tryConvert = (text: string) => { try { - setValue(parse(text)); + const value = parse(text); // this could throw in case of conversion error + setValue(value); setHasConversionError(false); } catch (error) { if (isObject(error) && error instanceof ConversionError) { @@ -62,6 +63,23 @@ export const useConverterField = ({ onChangeText?.(newText); }; + useFieldValidator({ + name: fieldConfig.name, + validator: () => { + try { + parse(textRef.current); + } catch (error) { + if (isObject(error) && error instanceof ConversionError) { + return error.message; + } + + throw error; + } + + return undefined; + }, + }); + useEffect(() => { if (hasConversionError) { return; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index aa61532c..33fdb855 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -131,4 +131,19 @@ describe('Converter field', () => { expect(converterFieldBag.current.value).toBe(1); expect(converterFieldBag.current.text).toBe('a'); }); + + it('Should return error from validator', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); + + const { onTextChange } = converterFieldBag.current; + const { validateForm, values } = formBag.current; + + await act(async () => { + await onTextChange('a'); + }); + + const errors = await validateForm(values.getValues()); + + expect(errors.test?.$error).toBe('hello'); + }); }); From cb6f7622318324a2325af3237e0b39475c45b9fb Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Fri, 25 Aug 2023 19:17:33 +0300 Subject: [PATCH 06/26] Handled one more test case --- packages/x/src/useConverterField.ts | 22 ++++++++++++----- packages/x/tests/useConverterField.test.tsx | 26 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 6746ae14..1231f1a5 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -11,19 +11,18 @@ export class ConversionError extends Error { export type ConverterFieldConfig = { parse: (value: string) => T; format: (value: T) => string; - - onChangeText?: (text: string) => void; } & FieldConfig; export type ConverterFieldBag = { text: string; onTextChange: (text: string) => void; + onFocus: () => void; + onBlur: () => void; } & FieldContext; export const useConverterField = ({ parse, format, - onChangeText, ...fieldConfig }: ConverterFieldConfig): ConverterFieldBag => { const fieldBag = useField(fieldConfig); @@ -33,6 +32,7 @@ export const useConverterField = ({ control: { setValue, setError }, } = fieldBag; + const [isFocused, setIsFocused] = useState(false); const [text, setText] = useState(() => format(value)); const textRef = useRef(text); textRef.current = text; @@ -60,7 +60,15 @@ export const useConverterField = ({ textRef.current = newText; setText(newText); tryConvert(newText); - onChangeText?.(newText); + }; + + const onFocus = () => { + setIsFocused(true); + }; + + const onBlur = () => { + setIsFocused(false); + tryConvert(text); }; useFieldValidator({ @@ -81,18 +89,20 @@ export const useConverterField = ({ }); useEffect(() => { - if (hasConversionError) { + if (isFocused || hasConversionError) { return; } const formattedValue = format(value); textRef.current = formattedValue; setText(formattedValue); - }, [value, format, hasConversionError]); + }, [value, format, hasConversionError, isFocused]); return { text, onTextChange, + onFocus, + onBlur, ...fieldBag, }; }; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 33fdb855..071b96b0 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -146,4 +146,30 @@ describe('Converter field', () => { expect(errors.test?.$error).toBe('hello'); }); + + // TODO: tricky test case, maybe behavior can change + it('Should ignore new value when field is focused and set old value when field is blurred', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); + + const { onFocus, onBlur } = converterFieldBag.current; + const { setFieldValue, paths } = formBag.current; + + await act(async () => { + await onFocus(); + }); + + await act(async () => { + await setFieldValue(paths.test, 1); + }); + + expect(converterFieldBag.current.text).toBe('0'); + expect(converterFieldBag.current.value).toBe(1); + + await act(async () => { + await onBlur(); + }); + + expect(converterFieldBag.current.text).toBe('0'); + expect(converterFieldBag.current.value).toBe(0); + }); }); From fe213ed044dde248aaefb8b80c8624a647dd8fc7 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 11:27:30 +0300 Subject: [PATCH 07/26] Set field touched=true on blur --- packages/x/src/useConverterField.ts | 5 +++-- packages/x/tests/useConverterField.test.tsx | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 1231f1a5..2a92df9e 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { FieldConfig, FieldContext, FieldError, useField, useFieldValidator } from '@reactive-forms/core'; +import { FieldConfig, FieldContext, FieldError, FieldTouched, useField, useFieldValidator } from '@reactive-forms/core'; import isObject from 'lodash/isObject'; export class ConversionError extends Error { @@ -29,7 +29,7 @@ export const useConverterField = ({ const { value, - control: { setValue, setError }, + control: { setValue, setError, setTouched }, } = fieldBag; const [isFocused, setIsFocused] = useState(false); @@ -68,6 +68,7 @@ export const useConverterField = ({ const onBlur = () => { setIsFocused(false); + setTouched({ $touched: true } as FieldTouched); tryConvert(text); }; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 071b96b0..03587848 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -172,4 +172,16 @@ describe('Converter field', () => { expect(converterFieldBag.current.text).toBe('0'); expect(converterFieldBag.current.value).toBe(0); }); + + it('Should set field touched=true on blur', async () => { + const { converterFieldBag } = renderUseConverterField(); + + const { onBlur } = converterFieldBag.current; + + await act(async () => { + await onBlur(); + }); + + expect(converterFieldBag.current.meta.touched?.$touched).toBe(true); + }); }); From 6e6cab1f26db2c1c030c0555dfbaf2461a2a0f89 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 11:47:38 +0300 Subject: [PATCH 08/26] Created new option "ignoreFormStateUpdatesWhileFocus" --- packages/x/src/useConverterField.ts | 8 +++- packages/x/tests/useConverterField.test.tsx | 41 ++++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 2a92df9e..65d397f2 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -11,6 +11,9 @@ export class ConversionError extends Error { export type ConverterFieldConfig = { parse: (value: string) => T; format: (value: T) => string; + + // An option that allow to ignore updates incoming from form level state while field is focused + ignoreFormStateUpdatesWhileFocus?: boolean; } & FieldConfig; export type ConverterFieldBag = { @@ -23,6 +26,7 @@ export type ConverterFieldBag = { export const useConverterField = ({ parse, format, + ignoreFormStateUpdatesWhileFocus, ...fieldConfig }: ConverterFieldConfig): ConverterFieldBag => { const fieldBag = useField(fieldConfig); @@ -90,14 +94,14 @@ export const useConverterField = ({ }); useEffect(() => { - if (isFocused || hasConversionError) { + if ((isFocused && ignoreFormStateUpdatesWhileFocus) || hasConversionError) { return; } const formattedValue = format(value); textRef.current = formattedValue; setText(formattedValue); - }, [value, format, hasConversionError, isFocused]); + }, [value, format, hasConversionError, isFocused, ignoreFormStateUpdatesWhileFocus]); return { text, diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 03587848..5e56b7bb 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -14,7 +14,14 @@ const defaultParse = (text: string) => { return parsingResult; }; -const renderUseConverterField = (parse: (value: string) => number = defaultParse) => { +type Config = { + parse?: (value: string) => number; + ignoreFormStateUpdatesWhileFocus?: boolean; +}; + +const renderUseConverterField = (config: Config = {}) => { + const { parse = defaultParse, ignoreFormStateUpdatesWhileFocus = false } = config; + const { result: formBag } = renderHook(() => useForm({ initialValues: { @@ -29,6 +36,7 @@ const renderUseConverterField = (parse: (value: string) => number = defaultParse parse, format: (value) => String(value), name: formBag.current.paths.test, + ignoreFormStateUpdatesWhileFocus, }), { wrapper: ({ children }) => ( @@ -106,8 +114,10 @@ describe('Converter field', () => { }); it('Should rethrow an error in case it is not ConversionError', () => { - const { converterFieldBag } = renderUseConverterField(() => { - throw new Error('custom'); + const { converterFieldBag } = renderUseConverterField({ + parse: () => { + throw new Error('custom'); + }, }); act(() => { @@ -147,9 +157,10 @@ describe('Converter field', () => { expect(errors.test?.$error).toBe('hello'); }); - // TODO: tricky test case, maybe behavior can change - it('Should ignore new value when field is focused and set old value when field is blurred', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); + it('Should ignore new value when field is focused and set old value when field is blurred (with option "ignoreFormStateUpdatesWhileFocus=true")', async () => { + const { converterFieldBag, formBag } = renderUseConverterField({ + ignoreFormStateUpdatesWhileFocus: true, + }); const { onFocus, onBlur } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -173,6 +184,24 @@ describe('Converter field', () => { expect(converterFieldBag.current.value).toBe(0); }); + it('Should set new value immediately when field is focused (with option "ignoreFormStateUpdatesWhileFocus=false")', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); + + const { onFocus } = converterFieldBag.current; + const { setFieldValue, paths } = formBag.current; + + await act(async () => { + await onFocus(); + }); + + await act(async () => { + await setFieldValue(paths.test, 1); + }); + + expect(converterFieldBag.current.text).toBe('1'); + expect(converterFieldBag.current.value).toBe(1); + }); + it('Should set field touched=true on blur', async () => { const { converterFieldBag } = renderUseConverterField(); From 424da596f82485abd3c7c00abaffe508358ff13e Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 13:21:42 +0300 Subject: [PATCH 09/26] Removed unused dependency --- packages/x/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/x/package.json b/packages/x/package.json index 46ffc8c4..98ed835f 100644 --- a/packages/x/package.json +++ b/packages/x/package.json @@ -48,7 +48,6 @@ ], "source": "src/index.ts", "dependencies": { - "lodash": "4.17.21", - "pxth": "0.6.0" + "lodash": "4.17.21" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c714d5f..06ae7316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,9 +221,6 @@ importers: lodash: specifier: 4.17.21 version: 4.17.21 - pxth: - specifier: 0.6.0 - version: 0.6.0 devDependencies: '@babel/core': specifier: 7.19.6 From 2c62e2da2add316e74922b7579557d11a75bcddc Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 13:37:07 +0300 Subject: [PATCH 10/26] Created forceSetValue function in useConverterField --- packages/x/src/useConverterField.ts | 14 ++++--- packages/x/tests/useConverterField.test.tsx | 42 ++++++++++----------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 65d397f2..116167c2 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -11,9 +11,6 @@ export class ConversionError extends Error { export type ConverterFieldConfig = { parse: (value: string) => T; format: (value: T) => string; - - // An option that allow to ignore updates incoming from form level state while field is focused - ignoreFormStateUpdatesWhileFocus?: boolean; } & FieldConfig; export type ConverterFieldBag = { @@ -26,7 +23,6 @@ export type ConverterFieldBag = { export const useConverterField = ({ parse, format, - ignoreFormStateUpdatesWhileFocus, ...fieldConfig }: ConverterFieldConfig): ConverterFieldBag => { const fieldBag = useField(fieldConfig); @@ -76,6 +72,11 @@ export const useConverterField = ({ tryConvert(text); }; + const forceSetValue = (value: T) => { + onTextChange(format(value)); + setValue(value); + }; + useFieldValidator({ name: fieldConfig.name, validator: () => { @@ -94,14 +95,14 @@ export const useConverterField = ({ }); useEffect(() => { - if ((isFocused && ignoreFormStateUpdatesWhileFocus) || hasConversionError) { + if (isFocused || hasConversionError) { return; } const formattedValue = format(value); textRef.current = formattedValue; setText(formattedValue); - }, [value, format, hasConversionError, isFocused, ignoreFormStateUpdatesWhileFocus]); + }, [value, format, hasConversionError, isFocused]); return { text, @@ -109,5 +110,6 @@ export const useConverterField = ({ onFocus, onBlur, ...fieldBag, + control: { ...fieldBag.control, setValue: forceSetValue }, }; }; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 5e56b7bb..0413704e 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -16,11 +16,10 @@ const defaultParse = (text: string) => { type Config = { parse?: (value: string) => number; - ignoreFormStateUpdatesWhileFocus?: boolean; }; const renderUseConverterField = (config: Config = {}) => { - const { parse = defaultParse, ignoreFormStateUpdatesWhileFocus = false } = config; + const { parse = defaultParse } = config; const { result: formBag } = renderHook(() => useForm({ @@ -36,7 +35,6 @@ const renderUseConverterField = (config: Config = {}) => { parse, format: (value) => String(value), name: formBag.current.paths.test, - ignoreFormStateUpdatesWhileFocus, }), { wrapper: ({ children }) => ( @@ -157,10 +155,8 @@ describe('Converter field', () => { expect(errors.test?.$error).toBe('hello'); }); - it('Should ignore new value when field is focused and set old value when field is blurred (with option "ignoreFormStateUpdatesWhileFocus=true")', async () => { - const { converterFieldBag, formBag } = renderUseConverterField({ - ignoreFormStateUpdatesWhileFocus: true, - }); + it('Should ignore new value when field is focused and set old value when field is blurred', async () => { + const { converterFieldBag, formBag } = renderUseConverterField(); const { onFocus, onBlur } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -184,33 +180,35 @@ describe('Converter field', () => { expect(converterFieldBag.current.value).toBe(0); }); - it('Should set new value immediately when field is focused (with option "ignoreFormStateUpdatesWhileFocus=false")', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); - - const { onFocus } = converterFieldBag.current; - const { setFieldValue, paths } = formBag.current; + it('Should set field touched=true on blur', async () => { + const { converterFieldBag } = renderUseConverterField(); - await act(async () => { - await onFocus(); - }); + const { onBlur } = converterFieldBag.current; await act(async () => { - await setFieldValue(paths.test, 1); + await onBlur(); }); - expect(converterFieldBag.current.text).toBe('1'); - expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.meta.touched?.$touched).toBe(true); }); - it('Should set field touched=true on blur', async () => { + it('Should set value both in form state and local text state', async () => { const { converterFieldBag } = renderUseConverterField(); - const { onBlur } = converterFieldBag.current; + const { + control: { setValue }, + onFocus, + } = converterFieldBag.current; await act(async () => { - await onBlur(); + await onFocus(); }); - expect(converterFieldBag.current.meta.touched?.$touched).toBe(true); + await act(async () => { + await setValue(1); + }); + + expect(converterFieldBag.current.value).toBe(1); + expect(converterFieldBag.current.text).toBe('1'); }); }); From abfe761c2505142c70508fc603a82b99f6b5bcd7 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 13:38:35 +0300 Subject: [PATCH 11/26] Wrap all functions with useCallback in useConverterField --- packages/x/src/useConverterField.ts | 69 ++++++++++++++++------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 116167c2..53dd1a6b 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { FieldConfig, FieldContext, FieldError, FieldTouched, useField, useFieldValidator } from '@reactive-forms/core'; import isObject from 'lodash/isObject'; @@ -39,43 +39,52 @@ export const useConverterField = ({ const [hasConversionError, setHasConversionError] = useState(false); - const tryConvert = (text: string) => { - try { - const value = parse(text); // this could throw in case of conversion error - setValue(value); - setHasConversionError(false); - } catch (error) { - if (isObject(error) && error instanceof ConversionError) { - setHasConversionError(true); - setError({ - $error: error.message, - } as FieldError); - } else { - throw error; + const tryConvert = useCallback( + (text: string) => { + try { + const value = parse(text); // this could throw in case of conversion error + setValue(value); + setHasConversionError(false); + } catch (error) { + if (isObject(error) && error instanceof ConversionError) { + setHasConversionError(true); + setError({ + $error: error.message, + } as FieldError); + } else { + throw error; + } } - } - }; - - const onTextChange = (newText: string) => { - textRef.current = newText; - setText(newText); - tryConvert(newText); - }; + }, + [parse, setError, setValue], + ); + + const onTextChange = useCallback( + (newText: string) => { + textRef.current = newText; + setText(newText); + tryConvert(newText); + }, + [tryConvert], + ); - const onFocus = () => { + const onFocus = useCallback(() => { setIsFocused(true); - }; + }, []); - const onBlur = () => { + const onBlur = useCallback(() => { setIsFocused(false); setTouched({ $touched: true } as FieldTouched); tryConvert(text); - }; + }, [setTouched, text, tryConvert]); - const forceSetValue = (value: T) => { - onTextChange(format(value)); - setValue(value); - }; + const forceSetValue = useCallback( + (value: T) => { + onTextChange(format(value)); + setValue(value); + }, + [format, onTextChange, setValue], + ); useFieldValidator({ name: fieldConfig.name, From 787e6569475e13ca0eae0499a8a10095f2b20645 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 13:50:36 +0300 Subject: [PATCH 12/26] Added test case for changing format function --- packages/x/tests/useConverterField.test.tsx | 77 ++++++++++++++++----- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 0413704e..4490e4b4 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -14,14 +14,17 @@ const defaultParse = (text: string) => { return parsingResult; }; +const defaultFormat = (value: number) => String(value); + type Config = { parse?: (value: string) => number; + format?: (value: number) => string; }; const renderUseConverterField = (config: Config = {}) => { - const { parse = defaultParse } = config; + const { parse = defaultParse, format = defaultFormat } = config; - const { result: formBag } = renderHook(() => + const formBag = renderHook(() => useForm({ initialValues: { test: 0, @@ -29,17 +32,23 @@ const renderUseConverterField = (config: Config = {}) => { }), ); - const { result: converterFieldBag } = renderHook( - () => + type Props = Required; + + const converterFieldBag = renderHook( + ({ format, parse }: Props) => useConverterField({ parse, - format: (value) => String(value), - name: formBag.current.paths.test, + format, + name: formBag.result.current.paths.test, }), { wrapper: ({ children }) => ( - {children} + {children} ), + initialProps: { + format, + parse, + }, }, ); @@ -51,7 +60,9 @@ const renderUseConverterField = (config: Config = {}) => { describe('Converter field', () => { it('Should update field with valid value', async () => { - const { converterFieldBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; expect(converterFieldBag.current.value).toBe(0); @@ -66,7 +77,9 @@ describe('Converter field', () => { }); it('Should set an error if conversion fails', async () => { - const { converterFieldBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; await act(async () => { @@ -79,7 +92,10 @@ describe('Converter field', () => { }); it('Should update text when form value changes', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + formBag: { result: formBag }, + } = renderUseConverterField(); const { paths } = formBag.current; @@ -92,7 +108,9 @@ describe('Converter field', () => { }); it('Should clear conversion error', async () => { - const { converterFieldBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; @@ -112,7 +130,9 @@ describe('Converter field', () => { }); it('Should rethrow an error in case it is not ConversionError', () => { - const { converterFieldBag } = renderUseConverterField({ + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField({ parse: () => { throw new Error('custom'); }, @@ -124,7 +144,10 @@ describe('Converter field', () => { }); it('Should not update text if there are some conversion errors', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + formBag: { result: formBag }, + } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -141,7 +164,10 @@ describe('Converter field', () => { }); it('Should return error from validator', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + formBag: { result: formBag }, + } = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; const { validateForm, values } = formBag.current; @@ -156,7 +182,10 @@ describe('Converter field', () => { }); it('Should ignore new value when field is focused and set old value when field is blurred', async () => { - const { converterFieldBag, formBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + formBag: { result: formBag }, + } = renderUseConverterField(); const { onFocus, onBlur } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -181,7 +210,9 @@ describe('Converter field', () => { }); it('Should set field touched=true on blur', async () => { - const { converterFieldBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField(); const { onBlur } = converterFieldBag.current; @@ -193,7 +224,9 @@ describe('Converter field', () => { }); it('Should set value both in form state and local text state', async () => { - const { converterFieldBag } = renderUseConverterField(); + const { + converterFieldBag: { result: converterFieldBag }, + } = renderUseConverterField(); const { control: { setValue }, @@ -211,4 +244,14 @@ describe('Converter field', () => { expect(converterFieldBag.current.value).toBe(1); expect(converterFieldBag.current.text).toBe('1'); }); + + it('Should reformat value when format function changes', () => { + const { converterFieldBag } = renderUseConverterField(); + + const format = jest.fn(() => 'test'); + + converterFieldBag.rerender({ format, parse: defaultParse }); + + expect(converterFieldBag.result.current.text).toBe('test'); + }); }); From c8fa353ebf70c6218fe948b3196669b94333d974 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 14:00:23 +0300 Subject: [PATCH 13/26] Created test for changing parse function --- packages/x/src/useConverterField.ts | 10 ++++++++++ packages/x/tests/useConverterField.test.tsx | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/x/src/useConverterField.ts b/packages/x/src/useConverterField.ts index 53dd1a6b..c0fdc236 100644 --- a/packages/x/src/useConverterField.ts +++ b/packages/x/src/useConverterField.ts @@ -113,6 +113,16 @@ export const useConverterField = ({ setText(formattedValue); }, [value, format, hasConversionError, isFocused]); + const tryConvertRef = useRef(tryConvert); + + useEffect(() => { + if (tryConvertRef.current !== tryConvert) { + tryConvert(textRef.current); // Parse text again when parse function changes + } + + tryConvertRef.current = tryConvert; + }, [tryConvert]); + return { text, onTextChange, diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 4490e4b4..1a6e87cb 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -250,8 +250,22 @@ describe('Converter field', () => { const format = jest.fn(() => 'test'); - converterFieldBag.rerender({ format, parse: defaultParse }); + act(() => { + converterFieldBag.rerender({ format, parse: defaultParse }); + }); expect(converterFieldBag.result.current.text).toBe('test'); }); + + it('Should parse text again when parse function changes', () => { + const { converterFieldBag } = renderUseConverterField(); + + const parse = jest.fn(() => 1); + + act(() => { + converterFieldBag.rerender({ format: defaultFormat, parse }); + }); + + expect(converterFieldBag.result.current.value).toBe(1); + }); }); From 4f4936f84ef7366b4d4fea98aaa9845149327a05 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sat, 26 Aug 2023 14:04:29 +0300 Subject: [PATCH 14/26] Fixed entrypoint config in package.json --- packages/x/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/x/package.json b/packages/x/package.json index 98ed835f..52c5daab 100644 --- a/packages/x/package.json +++ b/packages/x/package.json @@ -3,8 +3,8 @@ "description": "Advanced Reactive Forms components for rich eXperience", "version": "0.10.2", "main": "dist/index.js", - "module": "dist/core.esm.js", - "types": "dist/core.d.ts", + "module": "dist/x.esm.js", + "types": "dist/x.d.ts", "bugs": "https://github.com/fracht/reactive-forms/issues", "homepage": "https://github.com/fracht/reactive-forms#readme", "repository": "fracht/reactive-forms.git", From 6301354e3f38c19befaa1ddbd95db9e9b705a65e Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Sun, 27 Aug 2023 16:57:12 +0300 Subject: [PATCH 15/26] Created useIntegerField hook --- packages/x/src/useIntegerField.ts | 108 +++++++++++++++++ packages/x/tests/useConverterField.test.tsx | 53 ++------ packages/x/tests/useIntegerField.test.tsx | 127 ++++++++++++++++++++ 3 files changed, 248 insertions(+), 40 deletions(-) create mode 100644 packages/x/src/useIntegerField.ts create mode 100644 packages/x/tests/useIntegerField.test.tsx diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts new file mode 100644 index 00000000..0b3df82e --- /dev/null +++ b/packages/x/src/useIntegerField.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react'; +import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; + +import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; + +const INTEGER_REGEX = /^-?\d+$/; + +const formatInteger = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return ''; + } + + return value.toFixed(0); +}; + +export type IntegerFieldErrorMessages = { + invalidInput: string; + required: string; + lessThanMinValue: ((min: number) => string) | string; + moreThanMaxValue: ((max: number) => string) | string; +}; + +export const defaultErrorMessages: IntegerFieldErrorMessages = { + invalidInput: 'Must be integer', + required: 'Field is required', + lessThanMinValue: (min) => `Value should not be less than ${min.toFixed(0)}`, + moreThanMaxValue: (max) => `Value should not be more than ${max.toFixed(0)}`, +}; + +export type IntegerFieldConfig = FieldConfig & { + required?: boolean; + min?: number; + max?: number; + + formatValue?: (value: number | null | undefined) => string; + errorMessages?: IntegerFieldErrorMessages; +}; + +export type IntegerFieldBag = ConverterFieldBag & {}; + +export const useIntegerField = ({ + name, + validator, + schema, + required, + min, + max, + formatValue, + errorMessages = defaultErrorMessages, +}: IntegerFieldConfig): IntegerFieldBag => { + const { invalidInput, required: requiredError, lessThanMinValue, moreThanMaxValue } = errorMessages; + + const parseInteger = useCallback( + (text: string) => { + text = text.trim(); + + if (text.length === 0) { + return null; + } + + if (!INTEGER_REGEX.test(text)) { + throw new ConversionError(invalidInput); + } + + const value = Number.parseInt(text); + + if (Number.isNaN(value)) { + throw new ConversionError(invalidInput); + } + + return value; + }, + [invalidInput], + ); + + const integerBag = useConverterField({ + parse: parseInteger, + format: formatValue ?? formatInteger, + name, + validator, + schema, + }); + + useFieldValidator({ + name, + validator: (value) => { + if (required && typeof value !== 'number') { + return requiredError; + } + + if (typeof value !== 'number') { + return undefined; + } + + if (typeof min === 'number' && value < Math.round(min)) { + return typeof lessThanMinValue === 'function' ? lessThanMinValue(min) : lessThanMinValue; + } + + if (typeof max === 'number' && value > Math.round(max)) { + return typeof moreThanMaxValue === 'function' ? moreThanMaxValue(max) : moreThanMaxValue; + } + + return undefined; + }, + }); + + return integerBag; +}; diff --git a/packages/x/tests/useConverterField.test.tsx b/packages/x/tests/useConverterField.test.tsx index 1a6e87cb..eea70f02 100644 --- a/packages/x/tests/useConverterField.test.tsx +++ b/packages/x/tests/useConverterField.test.tsx @@ -52,17 +52,12 @@ const renderUseConverterField = (config: Config = {}) => { }, ); - return { - formBag, - converterFieldBag, - }; + return [converterFieldBag, formBag] as const; }; describe('Converter field', () => { it('Should update field with valid value', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }] = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; expect(converterFieldBag.current.value).toBe(0); @@ -77,9 +72,7 @@ describe('Converter field', () => { }); it('Should set an error if conversion fails', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }] = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; await act(async () => { @@ -92,10 +85,7 @@ describe('Converter field', () => { }); it('Should update text when form value changes', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - formBag: { result: formBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }, { result: formBag }] = renderUseConverterField(); const { paths } = formBag.current; @@ -108,9 +98,7 @@ describe('Converter field', () => { }); it('Should clear conversion error', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }] = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; @@ -130,9 +118,7 @@ describe('Converter field', () => { }); it('Should rethrow an error in case it is not ConversionError', () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField({ + const [{ result: converterFieldBag }] = renderUseConverterField({ parse: () => { throw new Error('custom'); }, @@ -144,10 +130,7 @@ describe('Converter field', () => { }); it('Should not update text if there are some conversion errors', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - formBag: { result: formBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }, { result: formBag }] = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -164,10 +147,7 @@ describe('Converter field', () => { }); it('Should return error from validator', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - formBag: { result: formBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }, { result: formBag }] = renderUseConverterField(); const { onTextChange } = converterFieldBag.current; const { validateForm, values } = formBag.current; @@ -182,10 +162,7 @@ describe('Converter field', () => { }); it('Should ignore new value when field is focused and set old value when field is blurred', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - formBag: { result: formBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }, { result: formBag }] = renderUseConverterField(); const { onFocus, onBlur } = converterFieldBag.current; const { setFieldValue, paths } = formBag.current; @@ -210,9 +187,7 @@ describe('Converter field', () => { }); it('Should set field touched=true on blur', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }] = renderUseConverterField(); const { onBlur } = converterFieldBag.current; @@ -224,9 +199,7 @@ describe('Converter field', () => { }); it('Should set value both in form state and local text state', async () => { - const { - converterFieldBag: { result: converterFieldBag }, - } = renderUseConverterField(); + const [{ result: converterFieldBag }] = renderUseConverterField(); const { control: { setValue }, @@ -246,7 +219,7 @@ describe('Converter field', () => { }); it('Should reformat value when format function changes', () => { - const { converterFieldBag } = renderUseConverterField(); + const [converterFieldBag] = renderUseConverterField(); const format = jest.fn(() => 'test'); @@ -258,7 +231,7 @@ describe('Converter field', () => { }); it('Should parse text again when parse function changes', () => { - const { converterFieldBag } = renderUseConverterField(); + const [converterFieldBag] = renderUseConverterField(); const parse = jest.fn(() => 1); diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx new file mode 100644 index 00000000..8c86fa83 --- /dev/null +++ b/packages/x/tests/useIntegerField.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { defaultErrorMessages, IntegerFieldConfig, useIntegerField } from '../src/useIntegerField'; + +type Config = Omit & { + initialValue?: number | null; +}; + +const renderUseIntegerField = (config: Config = {}) => { + const { initialValue = 0, ...initialProps } = config; + + const formBag = renderHook(() => + useForm({ + initialValues: { + test: initialValue, + }, + }), + ); + + const integerFieldBag = renderHook( + (props: Omit) => + useIntegerField({ + name: formBag.result.current.paths.test, + ...props, + }), + { + wrapper: ({ children }) => ( + {children} + ), + initialProps, + }, + ); + + return [integerFieldBag, formBag] as const; +}; + +describe('Integer field', () => { + it('Should format initial value correctly', () => { + const [{ result: integerFieldBag }] = renderUseIntegerField(); + + expect(integerFieldBag.current.text).toBe('0'); + expect(integerFieldBag.current.value).toBe(0); + }); + + it('Should set default error in case of conversion error and clear it afterwards', async () => { + const [{ result: integerFieldBag }] = renderUseIntegerField(); + + act(() => { + integerFieldBag.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + }); + + act(() => { + integerFieldBag.current.onTextChange('0'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if text was not parsed successfully', async () => { + const [{ result: integerFieldBag }] = renderUseIntegerField(); + + act(() => { + integerFieldBag.current.onTextChange('a'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + }); + }); + + it('Should set default error if field is required and empty', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ required: true }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, null); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.required); + }); + }); + + // FIXME: enable this test after fixing useFieldValidator + // it.skip('Should set validate field on initial render', async () => { + // const [{ result: integerFieldBag }] = renderUseIntegerField({ required: true, initialValue: null }); + + // expect(integerFieldBag.current.meta.error?.$error).toBe( + // (defaultErrorMessages.lessThanMinValue as (min: number) => string)(1), + // ); + // }); + + it('Should set default error if field value is less than min', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ min: 0 }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, -1); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe( + (defaultErrorMessages.lessThanMinValue as (min: number) => string)(0), + ); + }); + }); + + it('Should set default error if field value is more than max', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ max: 0 }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, 1); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe( + (defaultErrorMessages.moreThanMaxValue as (max: number) => string)(0), + ); + }); + }); +}); From a2732f5126395141120677ff3c5a60537223e98f Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 12:25:59 +0300 Subject: [PATCH 16/26] Added tests for custom errors in useIntegerField --- packages/x/src/useIntegerField.ts | 22 +++--- packages/x/tests/useIntegerField.test.tsx | 95 ++++++++++++++++++++--- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index 0b3df82e..2df65a81 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -16,8 +16,8 @@ const formatInteger = (value: number | null | undefined) => { export type IntegerFieldErrorMessages = { invalidInput: string; required: string; - lessThanMinValue: ((min: number) => string) | string; - moreThanMaxValue: ((max: number) => string) | string; + lessThanMinValue: (min: number) => string; + moreThanMaxValue: (max: number) => string; }; export const defaultErrorMessages: IntegerFieldErrorMessages = { @@ -33,7 +33,7 @@ export type IntegerFieldConfig = FieldConfig & { max?: number; formatValue?: (value: number | null | undefined) => string; - errorMessages?: IntegerFieldErrorMessages; + errorMessages?: Partial; }; export type IntegerFieldBag = ConverterFieldBag & {}; @@ -48,8 +48,6 @@ export const useIntegerField = ({ formatValue, errorMessages = defaultErrorMessages, }: IntegerFieldConfig): IntegerFieldBag => { - const { invalidInput, required: requiredError, lessThanMinValue, moreThanMaxValue } = errorMessages; - const parseInteger = useCallback( (text: string) => { text = text.trim(); @@ -58,19 +56,21 @@ export const useIntegerField = ({ return null; } + const errorMessage = errorMessages.invalidInput ?? defaultErrorMessages.invalidInput; + if (!INTEGER_REGEX.test(text)) { - throw new ConversionError(invalidInput); + throw new ConversionError(errorMessage); } const value = Number.parseInt(text); if (Number.isNaN(value)) { - throw new ConversionError(invalidInput); + throw new ConversionError(errorMessage); } return value; }, - [invalidInput], + [errorMessages.invalidInput], ); const integerBag = useConverterField({ @@ -85,7 +85,7 @@ export const useIntegerField = ({ name, validator: (value) => { if (required && typeof value !== 'number') { - return requiredError; + return errorMessages.required ?? defaultErrorMessages.required; } if (typeof value !== 'number') { @@ -93,11 +93,11 @@ export const useIntegerField = ({ } if (typeof min === 'number' && value < Math.round(min)) { - return typeof lessThanMinValue === 'function' ? lessThanMinValue(min) : lessThanMinValue; + return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min); } if (typeof max === 'number' && value > Math.round(max)) { - return typeof moreThanMaxValue === 'function' ? moreThanMaxValue(max) : moreThanMaxValue; + return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max); } return undefined; diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 8c86fa83..4bc4e3ca 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -44,6 +44,15 @@ describe('Integer field', () => { expect(integerFieldBag.current.value).toBe(0); }); + // FIXME: enable this test after fixing useFieldValidator + // it.skip('Should set validate field on initial render', async () => { + // const [{ result: integerFieldBag }] = renderUseIntegerField({ required: true, initialValue: null }); + + // expect(integerFieldBag.current.meta.error?.$error).toBe( + // (defaultErrorMessages.lessThanMinValue as (min: number) => string)(1), + // ); + // }); + it('Should set default error in case of conversion error and clear it afterwards', async () => { const [{ result: integerFieldBag }] = renderUseIntegerField(); @@ -88,15 +97,6 @@ describe('Integer field', () => { }); }); - // FIXME: enable this test after fixing useFieldValidator - // it.skip('Should set validate field on initial render', async () => { - // const [{ result: integerFieldBag }] = renderUseIntegerField({ required: true, initialValue: null }); - - // expect(integerFieldBag.current.meta.error?.$error).toBe( - // (defaultErrorMessages.lessThanMinValue as (min: number) => string)(1), - // ); - // }); - it('Should set default error if field value is less than min', async () => { const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ min: 0 }); @@ -124,4 +124,81 @@ describe('Integer field', () => { ); }); }); + + it('Should set custom error in case of conversion error and clear it afterwards', async () => { + const [{ result: integerFieldBag }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); + + act(() => { + integerFieldBag.current.onTextChange('0a'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + integerFieldBag.current.onTextChange('0'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if text was not parsed successfully', async () => { + const [{ result: integerFieldBag }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); + + act(() => { + integerFieldBag.current.onTextChange('a'); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + required: true, + errorMessages: { required: 'custom' }, + }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, null); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field value is less than min', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + min: 0, + errorMessages: { lessThanMinValue: () => 'custom' }, + }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, -1); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if field value is more than max', async () => { + const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + max: 0, + errorMessages: { moreThanMaxValue: () => 'custom' }, + }); + + act(() => { + formBag.current.setFieldValue(formBag.current.paths.test, 1); + }); + + await waitFor(() => { + expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + }); + }); }); From d970e2e938f928690a96829549621e40d75853a7 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 12:37:15 +0300 Subject: [PATCH 17/26] Added test --- packages/x/src/useIntegerField.ts | 4 ++-- packages/x/tests/useIntegerField.test.tsx | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index 2df65a81..b7a59d8b 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -45,7 +45,7 @@ export const useIntegerField = ({ required, min, max, - formatValue, + formatValue = formatInteger, errorMessages = defaultErrorMessages, }: IntegerFieldConfig): IntegerFieldBag => { const parseInteger = useCallback( @@ -75,7 +75,7 @@ export const useIntegerField = ({ const integerBag = useConverterField({ parse: parseInteger, - format: formatValue ?? formatInteger, + format: formatValue, name, validator, schema, diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 4bc4e3ca..1ff66341 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -201,4 +201,13 @@ describe('Integer field', () => { expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); }); }); + + it('Should be able to format integer differently', () => { + const formatValue = jest.fn(() => 'custom'); + const initialValue = 42; + const [{ result: integerFieldBag }] = renderUseIntegerField({ formatValue, initialValue }); + + expect(integerFieldBag.current.text).toBe('custom'); + expect(formatValue).toBeCalledWith(initialValue); + }); }); From c2d69d4052ad7a8343a1fa791a6c264fbba843a9 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 12:39:37 +0300 Subject: [PATCH 18/26] Added changeset --- .changeset/red-badgers-doubt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-badgers-doubt.md 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 From 5e8ae6adc6749f2169a2861ca5ed28d4e8b969da Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Mon, 28 Aug 2023 15:40:26 +0300 Subject: [PATCH 19/26] Refactored tests --- packages/x/tests/useIntegerField.test.tsx | 91 ++++++++++------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 1ff66341..e4cbc137 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -19,7 +19,7 @@ const renderUseIntegerField = (config: Config = {}) => { }), ); - const integerFieldBag = renderHook( + const result = renderHook( (props: Omit) => useIntegerField({ name: formBag.result.current.paths.test, @@ -33,181 +33,172 @@ const renderUseIntegerField = (config: Config = {}) => { }, ); - return [integerFieldBag, formBag] as const; + return [result, formBag] as const; }; describe('Integer field', () => { it('Should format initial value correctly', () => { - const [{ result: integerFieldBag }] = renderUseIntegerField(); + const [{ result }] = renderUseIntegerField(); - expect(integerFieldBag.current.text).toBe('0'); - expect(integerFieldBag.current.value).toBe(0); + expect(result.current.text).toBe('0'); + expect(result.current.value).toBe(0); }); - // FIXME: enable this test after fixing useFieldValidator - // it.skip('Should set validate field on initial render', async () => { - // const [{ result: integerFieldBag }] = renderUseIntegerField({ required: true, initialValue: null }); - - // expect(integerFieldBag.current.meta.error?.$error).toBe( - // (defaultErrorMessages.lessThanMinValue as (min: number) => string)(1), - // ); - // }); - it('Should set default error in case of conversion error and clear it afterwards', async () => { - const [{ result: integerFieldBag }] = renderUseIntegerField(); + const [{ result }] = renderUseIntegerField(); act(() => { - integerFieldBag.current.onTextChange('0a'); + result.current.onTextChange('0a'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); }); act(() => { - integerFieldBag.current.onTextChange('0'); + result.current.onTextChange('0'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBeUndefined(); + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); it('Should set default error if text was not parsed successfully', async () => { - const [{ result: integerFieldBag }] = renderUseIntegerField(); + const [{ result }] = renderUseIntegerField(); act(() => { - integerFieldBag.current.onTextChange('a'); + result.current.onTextChange('a'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); }); }); it('Should set default error if field is required and empty', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ required: true }); + const [{ result }] = renderUseIntegerField({ required: true }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, null); + result.current.control.setValue(null); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe(defaultErrorMessages.required); + expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.required); }); }); it('Should set default error if field value is less than min', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ min: 0 }); + const [{ result }] = renderUseIntegerField({ min: 0 }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, -1); + result.current.control.setValue(-1); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe( + expect(result.current.meta.error?.$error).toBe( (defaultErrorMessages.lessThanMinValue as (min: number) => string)(0), ); }); }); it('Should set default error if field value is more than max', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ max: 0 }); + const [{ result }] = renderUseIntegerField({ max: 0 }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, 1); + result.current.control.setValue(1); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe( + expect(result.current.meta.error?.$error).toBe( (defaultErrorMessages.moreThanMaxValue as (max: number) => string)(0), ); }); }); it('Should set custom error in case of conversion error and clear it afterwards', async () => { - const [{ result: integerFieldBag }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); + const [{ result }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); act(() => { - integerFieldBag.current.onTextChange('0a'); + result.current.onTextChange('0a'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBe('custom'); }); act(() => { - integerFieldBag.current.onTextChange('0'); + result.current.onTextChange('0'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBeUndefined(); + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); it('Should set custom error if text was not parsed successfully', async () => { - const [{ result: integerFieldBag }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); + const [{ result }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); act(() => { - integerFieldBag.current.onTextChange('a'); + result.current.onTextChange('a'); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBe('custom'); }); }); it('Should set custom error if field is required and empty', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + const [{ result }] = renderUseIntegerField({ required: true, errorMessages: { required: 'custom' }, }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, null); + result.current.control.setValue(null); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBe('custom'); }); }); it('Should set custom error if field value is less than min', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + const [{ result }] = renderUseIntegerField({ min: 0, errorMessages: { lessThanMinValue: () => 'custom' }, }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, -1); + result.current.control.setValue(-1); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBe('custom'); }); }); it('Should set custom error if field value is more than max', async () => { - const [{ result: integerFieldBag }, { result: formBag }] = renderUseIntegerField({ + const [{ result }] = renderUseIntegerField({ max: 0, errorMessages: { moreThanMaxValue: () => 'custom' }, }); act(() => { - formBag.current.setFieldValue(formBag.current.paths.test, 1); + result.current.control.setValue(1); }); await waitFor(() => { - expect(integerFieldBag.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBe('custom'); }); }); it('Should be able to format integer differently', () => { const formatValue = jest.fn(() => 'custom'); const initialValue = 42; - const [{ result: integerFieldBag }] = renderUseIntegerField({ formatValue, initialValue }); + const [{ result }] = renderUseIntegerField({ formatValue, initialValue }); - expect(integerFieldBag.current.text).toBe('custom'); + expect(result.current.text).toBe('custom'); expect(formatValue).toBeCalledWith(initialValue); }); }); From 3c11ed7f09e04e9936e02ffafa395d330c7ffab3 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Wed, 30 Aug 2023 12:02:27 +0300 Subject: [PATCH 20/26] Refactored IntegerField custom errors API --- packages/x/src/useIntegerField.ts | 65 +++++++++++++---------- packages/x/tests/useIntegerField.test.tsx | 52 +++++++++++------- 2 files changed, 72 insertions(+), 45 deletions(-) diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index b7a59d8b..8547d1ce 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -1,8 +1,15 @@ import { useCallback } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; +import isFunction from 'lodash/isFunction'; +import isNil from 'lodash/isNil'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; +export const defaultRequiredError = 'Field is required'; +export const defaultInvalidInputError = 'Must be integer'; +export const defaultMinValueError = (min: number) => `Value should not be less than ${min.toFixed(0)}`; +export const defaultMaxValueError = (max: number) => `Value should not be more than ${max.toFixed(0)}`; + const INTEGER_REGEX = /^-?\d+$/; const formatInteger = (value: number | null | undefined) => { @@ -13,27 +20,15 @@ const formatInteger = (value: number | null | undefined) => { return value.toFixed(0); }; -export type IntegerFieldErrorMessages = { - invalidInput: string; - required: string; - lessThanMinValue: (min: number) => string; - moreThanMaxValue: (max: number) => string; -}; - -export const defaultErrorMessages: IntegerFieldErrorMessages = { - invalidInput: 'Must be integer', - required: 'Field is required', - lessThanMinValue: (min) => `Value should not be less than ${min.toFixed(0)}`, - moreThanMaxValue: (max) => `Value should not be more than ${max.toFixed(0)}`, -}; +export type ErrorTuple = [value: T, message: string | ((value: T) => string)]; export type IntegerFieldConfig = FieldConfig & { - required?: boolean; - min?: number; - max?: number; + required?: boolean | string; + invalidInput?: string; + min?: number | ErrorTuple; + max?: number | ErrorTuple; formatValue?: (value: number | null | undefined) => string; - errorMessages?: Partial; }; export type IntegerFieldBag = ConverterFieldBag & {}; @@ -43,10 +38,10 @@ export const useIntegerField = ({ validator, schema, required, + invalidInput, min, max, formatValue = formatInteger, - errorMessages = defaultErrorMessages, }: IntegerFieldConfig): IntegerFieldBag => { const parseInteger = useCallback( (text: string) => { @@ -56,21 +51,21 @@ export const useIntegerField = ({ return null; } - const errorMessage = errorMessages.invalidInput ?? defaultErrorMessages.invalidInput; + const parseError = invalidInput ?? defaultInvalidInputError; if (!INTEGER_REGEX.test(text)) { - throw new ConversionError(errorMessage); + throw new ConversionError(parseError); } const value = Number.parseInt(text); if (Number.isNaN(value)) { - throw new ConversionError(errorMessage); + throw new ConversionError(parseError); } return value; }, - [errorMessages.invalidInput], + [invalidInput], ); const integerBag = useConverterField({ @@ -85,19 +80,35 @@ export const useIntegerField = ({ 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 < Math.round(min)) { - return (errorMessages.lessThanMinValue ?? defaultErrorMessages.lessThanMinValue)(min); + 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); + } } - if (typeof max === 'number' && value > Math.round(max)) { - return (errorMessages.moreThanMaxValue ?? defaultErrorMessages.moreThanMaxValue)(max); + 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); + } } return undefined; diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index e4cbc137..4607b918 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -2,7 +2,14 @@ import React from 'react'; import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { defaultErrorMessages, IntegerFieldConfig, useIntegerField } from '../src/useIntegerField'; +import { + defaultInvalidInputError, + defaultMaxValueError, + defaultMinValueError, + defaultRequiredError, + IntegerFieldConfig, + useIntegerField, +} from '../src/useIntegerField'; type Config = Omit & { initialValue?: number | null; @@ -52,7 +59,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); act(() => { @@ -72,7 +79,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.invalidInput); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); }); @@ -84,7 +91,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultErrorMessages.required); + expect(result.current.meta.error?.$error).toBe(defaultRequiredError); }); }); @@ -96,9 +103,15 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe( - (defaultErrorMessages.lessThanMinValue as (min: number) => string)(0), - ); + expect(result.current.meta.error?.$error).toBe(defaultMinValueError(0)); + }); + + act(() => { + result.current.control.setValue(0); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); @@ -110,14 +123,20 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe( - (defaultErrorMessages.moreThanMaxValue as (max: number) => string)(0), - ); + expect(result.current.meta.error?.$error).toBe(defaultMaxValueError(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({ errorMessages: { invalidInput: 'custom' } }); + const [{ result }] = renderUseIntegerField({ invalidInput: 'custom' }); act(() => { result.current.onTextChange('0a'); @@ -137,7 +156,7 @@ describe('Integer field', () => { }); it('Should set custom error if text was not parsed successfully', async () => { - const [{ result }] = renderUseIntegerField({ errorMessages: { invalidInput: 'custom' } }); + const [{ result }] = renderUseIntegerField({ invalidInput: 'custom' }); act(() => { result.current.onTextChange('a'); @@ -150,8 +169,7 @@ describe('Integer field', () => { it('Should set custom error if field is required and empty', async () => { const [{ result }] = renderUseIntegerField({ - required: true, - errorMessages: { required: 'custom' }, + required: 'custom', }); act(() => { @@ -165,8 +183,7 @@ describe('Integer field', () => { it('Should set custom error if field value is less than min', async () => { const [{ result }] = renderUseIntegerField({ - min: 0, - errorMessages: { lessThanMinValue: () => 'custom' }, + min: [0, 'custom'], }); act(() => { @@ -180,8 +197,7 @@ describe('Integer field', () => { it('Should set custom error if field value is more than max', async () => { const [{ result }] = renderUseIntegerField({ - max: 0, - errorMessages: { moreThanMaxValue: () => 'custom' }, + max: [0, 'custom'], }); act(() => { From b1d42cd4688de78392e2f0a32dffd60232375f5f Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Wed, 30 Aug 2023 12:24:37 +0300 Subject: [PATCH 21/26] Covered more test cases in useIntegerField --- packages/x/tests/useIntegerField.test.tsx | 27 +++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 4607b918..56e49137 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -59,27 +59,26 @@ describe('Integer field', () => { }); await waitFor(() => { + expect(result.current.value).toBe(0); expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); act(() => { - result.current.onTextChange('0'); + result.current.onTextChange('a0'); }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBeUndefined(); + expect(result.current.value).toBe(0); + expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); }); - }); - - it('Should set default error if text was not parsed successfully', async () => { - const [{ result }] = renderUseIntegerField(); act(() => { - result.current.onTextChange('a'); + result.current.onTextChange('1'); }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.value).toBe(1); + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); @@ -147,23 +146,19 @@ describe('Integer field', () => { }); act(() => { - result.current.onTextChange('0'); + result.current.onTextChange('a0'); }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBeUndefined(); + expect(result.current.meta.error?.$error).toBe('custom'); }); - }); - - it('Should set custom error if text was not parsed successfully', async () => { - const [{ result }] = renderUseIntegerField({ invalidInput: 'custom' }); act(() => { - result.current.onTextChange('a'); + result.current.onTextChange('0'); }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe('custom'); + expect(result.current.meta.error?.$error).toBeUndefined(); }); }); From 412823a4c44b3378cf86679a2ba33b0543ce1830 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 16:05:13 +0300 Subject: [PATCH 22/26] Extracted IntegerFieldI18nContext --- packages/x/src/IntegerFieldI18n.tsx | 30 +++++++++++++ packages/x/src/useIntegerField.ts | 51 +++++++---------------- packages/x/tests/useIntegerField.test.tsx | 35 +++++++++++++--- 3 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 packages/x/src/IntegerFieldI18n.tsx diff --git a/packages/x/src/IntegerFieldI18n.tsx b/packages/x/src/IntegerFieldI18n.tsx new file mode 100644 index 00000000..c00734a7 --- /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 more 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/useIntegerField.ts b/packages/x/src/useIntegerField.ts index 8547d1ce..2b4e742c 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -1,8 +1,7 @@ -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; -import isFunction from 'lodash/isFunction'; -import isNil from 'lodash/isNil'; +import { IntegerFieldI18nContext } from './IntegerFieldI18n'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; export const defaultRequiredError = 'Field is required'; @@ -20,13 +19,10 @@ const formatInteger = (value: number | null | undefined) => { return value.toFixed(0); }; -export type ErrorTuple = [value: T, message: string | ((value: T) => string)]; - export type IntegerFieldConfig = FieldConfig & { - required?: boolean | string; - invalidInput?: string; - min?: number | ErrorTuple; - max?: number | ErrorTuple; + required?: boolean; + min?: number; + max?: number; formatValue?: (value: number | null | undefined) => string; }; @@ -38,11 +34,12 @@ export const useIntegerField = ({ validator, schema, required, - invalidInput, min, max, formatValue = formatInteger, }: IntegerFieldConfig): IntegerFieldBag => { + const i18n = useContext(IntegerFieldI18nContext); + const parseInteger = useCallback( (text: string) => { text = text.trim(); @@ -51,21 +48,19 @@ export const useIntegerField = ({ return null; } - const parseError = invalidInput ?? defaultInvalidInputError; - if (!INTEGER_REGEX.test(text)) { - throw new ConversionError(parseError); + throw new ConversionError(i18n.invalidInput); } const value = Number.parseInt(text); if (Number.isNaN(value)) { - throw new ConversionError(parseError); + throw new ConversionError(i18n.invalidInput); } return value; }, - [invalidInput], + [i18n.invalidInput], ); const integerBag = useConverterField({ @@ -80,35 +75,19 @@ export const useIntegerField = ({ 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); - } + if (typeof min === 'number' && value < min) { + return i18n.minValue(min); } - 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); - } + if (typeof max === 'number' && value > max) { + return i18n.maxValue(max); } return undefined; diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 56e49137..c74cf793 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; +import { IntegerFieldI18n, IntegerFieldI18nContextProvider } from '../src/IntegerFieldI18n'; import { defaultInvalidInputError, defaultMaxValueError, @@ -13,10 +14,11 @@ import { type Config = Omit & { initialValue?: number | null; + i18n?: Partial; }; const renderUseIntegerField = (config: Config = {}) => { - const { initialValue = 0, ...initialProps } = config; + const { initialValue = 0, i18n, ...initialProps } = config; const formBag = renderHook(() => useForm({ @@ -34,7 +36,9 @@ const renderUseIntegerField = (config: Config = {}) => { }), { wrapper: ({ children }) => ( - {children} + + {children} + ), initialProps, }, @@ -135,7 +139,11 @@ describe('Integer field', () => { }); it('Should set custom error in case of conversion error and clear it afterwards', async () => { - const [{ result }] = renderUseIntegerField({ invalidInput: 'custom' }); + const [{ result }] = renderUseIntegerField({ + i18n: { + invalidInput: 'custom', + }, + }); act(() => { result.current.onTextChange('0a'); @@ -164,7 +172,10 @@ describe('Integer field', () => { it('Should set custom error if field is required and empty', async () => { const [{ result }] = renderUseIntegerField({ - required: 'custom', + required: true, + i18n: { + required: 'custom', + }, }); act(() => { @@ -177,8 +188,13 @@ describe('Integer field', () => { }); it('Should set custom error if field value is less than min', async () => { + const minValue = jest.fn(() => 'custom'); + const [{ result }] = renderUseIntegerField({ - min: [0, 'custom'], + min: 0, + i18n: { + minValue, + }, }); act(() => { @@ -187,12 +203,18 @@ describe('Integer field', () => { 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, 'custom'], + max: 0, + i18n: { + maxValue, + }, }); act(() => { @@ -201,6 +223,7 @@ describe('Integer field', () => { await waitFor(() => { expect(result.current.meta.error?.$error).toBe('custom'); + expect(maxValue).toBeCalledWith(0); }); }); From e0e5bf9bc6e152603939171f83f7f0074b825f67 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 16:08:06 +0300 Subject: [PATCH 23/26] Refactoring --- packages/x/src/IntegerFieldI18n.tsx | 2 +- packages/x/src/useIntegerField.ts | 5 ----- packages/x/tests/useIntegerField.test.tsx | 21 +++++++-------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/x/src/IntegerFieldI18n.tsx b/packages/x/src/IntegerFieldI18n.tsx index c00734a7..0a8fe0ee 100644 --- a/packages/x/src/IntegerFieldI18n.tsx +++ b/packages/x/src/IntegerFieldI18n.tsx @@ -12,7 +12,7 @@ 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 more than ${max.toFixed(0)}`, + maxValue: (max: number) => `Value should not be greater than ${max.toFixed(0)}`, }; export const IntegerFieldI18nContext = createContext(defaultIntegerFieldI18n); diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index 2b4e742c..53b8189d 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -4,11 +4,6 @@ import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; import { IntegerFieldI18nContext } from './IntegerFieldI18n'; import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; -export const defaultRequiredError = 'Field is required'; -export const defaultInvalidInputError = 'Must be integer'; -export const defaultMinValueError = (min: number) => `Value should not be less than ${min.toFixed(0)}`; -export const defaultMaxValueError = (max: number) => `Value should not be more than ${max.toFixed(0)}`; - const INTEGER_REGEX = /^-?\d+$/; const formatInteger = (value: number | null | undefined) => { diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index c74cf793..4ee8f569 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -2,15 +2,8 @@ import React from 'react'; import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { IntegerFieldI18n, IntegerFieldI18nContextProvider } from '../src/IntegerFieldI18n'; -import { - defaultInvalidInputError, - defaultMaxValueError, - defaultMinValueError, - defaultRequiredError, - IntegerFieldConfig, - useIntegerField, -} from '../src/useIntegerField'; +import { defaultIntegerFieldI18n, IntegerFieldI18n, IntegerFieldI18nContextProvider } from '../src/IntegerFieldI18n'; +import { IntegerFieldConfig, useIntegerField } from '../src/useIntegerField'; type Config = Omit & { initialValue?: number | null; @@ -64,7 +57,7 @@ describe('Integer field', () => { await waitFor(() => { expect(result.current.value).toBe(0); - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput); }); act(() => { @@ -73,7 +66,7 @@ describe('Integer field', () => { await waitFor(() => { expect(result.current.value).toBe(0); - expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput); }); act(() => { @@ -94,7 +87,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultRequiredError); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.required); }); }); @@ -106,7 +99,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultMinValueError(0)); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.minValue(0)); }); act(() => { @@ -126,7 +119,7 @@ describe('Integer field', () => { }); await waitFor(() => { - expect(result.current.meta.error?.$error).toBe(defaultMaxValueError(0)); + expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.maxValue(0)); }); act(() => { From 04922e1cd292e97a2c19d37055cfdaa6fd5597fb Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 17:53:10 +0300 Subject: [PATCH 24/26] Removed unused type --- packages/x/src/useIntegerField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index 53b8189d..e4ac7220 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -22,7 +22,7 @@ export type IntegerFieldConfig = FieldConfig & { formatValue?: (value: number | null | undefined) => string; }; -export type IntegerFieldBag = ConverterFieldBag & {}; +export type IntegerFieldBag = ConverterFieldBag; export const useIntegerField = ({ name, From 8bc9f27948035a78a43a3d9a04b0442473578511 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 18:00:11 +0300 Subject: [PATCH 25/26] Extracted `ValueConverter` type --- packages/x/src/formatInteger.ts | 7 ++++++ packages/x/src/useConverterField.ts | 6 +++-- packages/x/src/useIntegerField.ts | 30 ++++++++++------------- packages/x/tests/useIntegerField.test.tsx | 20 ++++++++++++--- 4 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 packages/x/src/formatInteger.ts 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 index e4ac7220..b2a9b55c 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -1,26 +1,17 @@ import { useCallback, useContext } from 'react'; import { FieldConfig, useFieldValidator } from '@reactive-forms/core'; +import { formatInteger } from './formatInteger'; import { IntegerFieldI18nContext } from './IntegerFieldI18n'; -import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField'; +import { ConversionError, ConverterFieldBag, useConverterField, ValueConverter } from './useConverterField'; const INTEGER_REGEX = /^-?\d+$/; -const formatInteger = (value: number | null | undefined) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return ''; - } - - return value.toFixed(0); -}; - export type IntegerFieldConfig = FieldConfig & { required?: boolean; min?: number; max?: number; - - formatValue?: (value: number | null | undefined) => string; -}; +} & Partial>; export type IntegerFieldBag = ConverterFieldBag; @@ -31,12 +22,17 @@ export const useIntegerField = ({ required, min, max, - formatValue = formatInteger, + parse: customParse, + format = formatInteger, }: IntegerFieldConfig): IntegerFieldBag => { const i18n = useContext(IntegerFieldI18nContext); - const parseInteger = useCallback( + const parse = useCallback( (text: string) => { + if (customParse) { + return customParse(text); + } + text = text.trim(); if (text.length === 0) { @@ -55,12 +51,12 @@ export const useIntegerField = ({ return value; }, - [i18n.invalidInput], + [customParse, i18n.invalidInput], ); const integerBag = useConverterField({ - parse: parseInteger, - format: formatValue, + parse, + format, name, validator, schema, diff --git a/packages/x/tests/useIntegerField.test.tsx b/packages/x/tests/useIntegerField.test.tsx index 4ee8f569..88767015 100644 --- a/packages/x/tests/useIntegerField.test.tsx +++ b/packages/x/tests/useIntegerField.test.tsx @@ -221,11 +221,25 @@ describe('Integer field', () => { }); it('Should be able to format integer differently', () => { - const formatValue = jest.fn(() => 'custom'); + const format = jest.fn(() => 'custom'); const initialValue = 42; - const [{ result }] = renderUseIntegerField({ formatValue, initialValue }); + const [{ result }] = renderUseIntegerField({ format, initialValue }); expect(result.current.text).toBe('custom'); - expect(formatValue).toBeCalledWith(initialValue); + 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'); + }); }); }); From 41b1454d616a400dd13096218970a0b0e5dc5080 Mon Sep 17 00:00:00 2001 From: Aleksandras Sukelovic Date: Tue, 12 Sep 2023 18:06:24 +0300 Subject: [PATCH 26/26] Small fix --- packages/x/src/useIntegerField.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/x/src/useIntegerField.ts b/packages/x/src/useIntegerField.ts index b2a9b55c..8800b467 100644 --- a/packages/x/src/useIntegerField.ts +++ b/packages/x/src/useIntegerField.ts @@ -29,12 +29,12 @@ export const useIntegerField = ({ const parse = useCallback( (text: string) => { + text = text.trim(); + if (customParse) { return customParse(text); } - text = text.trim(); - if (text.length === 0) { return null; }