Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create useIntegerField hook #291

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
29116ff
Created `useConverterField` hook
AlexShukel Aug 25, 2023
7b3ccb8
Merge branch 'main' into create-converter-field-abstraction
AlexShukel Aug 25, 2023
3b753e6
Synchronize form value with text state
AlexShukel Aug 25, 2023
d008ef8
Created changeset
AlexShukel Aug 25, 2023
95da53a
Added more tests
AlexShukel Aug 25, 2023
da1df9b
Added validator in ConverterField
AlexShukel Aug 25, 2023
cb6f762
Handled one more test case
AlexShukel Aug 25, 2023
fe213ed
Set field touched=true on blur
AlexShukel Aug 26, 2023
6e6cab1
Created new option "ignoreFormStateUpdatesWhileFocus"
AlexShukel Aug 26, 2023
424da59
Removed unused dependency
AlexShukel Aug 26, 2023
2c62e2d
Created forceSetValue function in useConverterField
AlexShukel Aug 26, 2023
abfe761
Wrap all functions with useCallback in useConverterField
AlexShukel Aug 26, 2023
787e656
Added test case for changing format function
AlexShukel Aug 26, 2023
c8fa353
Created test for changing parse function
AlexShukel Aug 26, 2023
4f4936f
Fixed entrypoint config in package.json
AlexShukel Aug 26, 2023
6301354
Created useIntegerField hook
AlexShukel Aug 27, 2023
fbc42f6
Merge branch 'main' into create-useIntegerField
AlexShukel Aug 27, 2023
a2732f5
Added tests for custom errors in useIntegerField
AlexShukel Aug 28, 2023
d970e2e
Added test
AlexShukel Aug 28, 2023
c2d69d4
Added changeset
AlexShukel Aug 28, 2023
5e8ae6a
Refactored tests
AlexShukel Aug 28, 2023
3c11ed7
Refactored IntegerField custom errors API
AlexShukel Aug 30, 2023
b1d42cd
Covered more test cases in useIntegerField
AlexShukel Aug 30, 2023
412823a
Extracted IntegerFieldI18nContext
AlexShukel Sep 12, 2023
e0e5bf9
Refactoring
AlexShukel Sep 12, 2023
04922e1
Removed unused type
AlexShukel Sep 12, 2023
8bc9f27
Extracted `ValueConverter` type
AlexShukel Sep 12, 2023
41b1454
Small fix
AlexShukel Sep 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-badgers-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@reactive-forms/x': minor
---

Created useIntegerField hook
30 changes: 30 additions & 0 deletions packages/x/src/IntegerFieldI18n.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { createContext, PropsWithChildren } from 'react';
import merge from 'lodash/merge';

export type IntegerFieldI18n = {
required: string;
invalidInput: string;
minValue: (value: number) => string;
maxValue: (value: number) => string;
};

export const defaultIntegerFieldI18n: IntegerFieldI18n = {
required: 'Field is required',
invalidInput: 'Must be integer',
minValue: (min: number) => `Value should not be less than ${min.toFixed(0)}`,
maxValue: (max: number) => `Value should not be greater than ${max.toFixed(0)}`,
};

export const IntegerFieldI18nContext = createContext<IntegerFieldI18n>(defaultIntegerFieldI18n);

export type IntegerFieldI18nContextProviderProps = PropsWithChildren<{
i18n?: Partial<IntegerFieldI18n>;
}>;

export const IntegerFieldI18nContextProvider = ({ children, i18n }: IntegerFieldI18nContextProviderProps) => {
return (
<IntegerFieldI18nContext.Provider value={merge(defaultIntegerFieldI18n, i18n)}>
{children}
</IntegerFieldI18nContext.Provider>
);
};
7 changes: 7 additions & 0 deletions packages/x/src/formatInteger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const formatInteger = (value: number | null | undefined) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '';
}

return value.toFixed(0);
};
6 changes: 4 additions & 2 deletions packages/x/src/useConverterField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export class ConversionError extends Error {
}
}

export type ConverterFieldConfig<T> = {
export type ValueConverter<T> = {
parse: (value: string) => T;
format: (value: T) => string;
} & FieldConfig<T>;
};

export type ConverterFieldConfig<T> = ValueConverter<T> & FieldConfig<T>;

export type ConverterFieldBag<T> = {
text: string;
Expand Down
89 changes: 89 additions & 0 deletions packages/x/src/useIntegerField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useCallback, useContext } from 'react';
import { FieldConfig, useFieldValidator } from '@reactive-forms/core';

import { formatInteger } from './formatInteger';
import { IntegerFieldI18nContext } from './IntegerFieldI18n';
import { ConversionError, ConverterFieldBag, useConverterField, ValueConverter } from './useConverterField';

const INTEGER_REGEX = /^-?\d+$/;

export type IntegerFieldConfig = FieldConfig<number | null | undefined> & {
required?: boolean;
min?: number;
max?: number;
} & Partial<ValueConverter<number | null | undefined>>;

export type IntegerFieldBag = ConverterFieldBag<number | null | undefined>;

export const useIntegerField = ({
name,
validator,
schema,
required,
min,
max,
parse: customParse,
format = formatInteger,
}: IntegerFieldConfig): IntegerFieldBag => {
const i18n = useContext(IntegerFieldI18nContext);

const parse = useCallback(
(text: string) => {
text = text.trim();

if (customParse) {
return customParse(text);
}

if (text.length === 0) {
return null;
}

if (!INTEGER_REGEX.test(text)) {
throw new ConversionError(i18n.invalidInput);
}

const value = Number.parseInt(text);

if (Number.isNaN(value)) {
throw new ConversionError(i18n.invalidInput);
}

return value;
},
[customParse, i18n.invalidInput],
);

const integerBag = useConverterField({
parse,
format,
name,
validator,
schema,
});

useFieldValidator({
name,
validator: (value) => {
if (required && typeof value !== 'number') {
return i18n.required;
}

if (typeof value !== 'number') {
return undefined;
}

if (typeof min === 'number' && value < min) {
return i18n.minValue(min);
}

if (typeof max === 'number' && value > max) {
return i18n.maxValue(max);
}

return undefined;
},
});

return integerBag;
};
245 changes: 245 additions & 0 deletions packages/x/tests/useIntegerField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import React from 'react';
import { ReactiveFormProvider, useForm } from '@reactive-forms/core';
import { act, renderHook, waitFor } from '@testing-library/react';

import { defaultIntegerFieldI18n, IntegerFieldI18n, IntegerFieldI18nContextProvider } from '../src/IntegerFieldI18n';
import { IntegerFieldConfig, useIntegerField } from '../src/useIntegerField';

type Config = Omit<IntegerFieldConfig, 'name'> & {
initialValue?: number | null;
i18n?: Partial<IntegerFieldI18n>;
};

const renderUseIntegerField = (config: Config = {}) => {
const { initialValue = 0, i18n, ...initialProps } = config;

const formBag = renderHook(() =>
useForm({
initialValues: {
test: initialValue,
},
}),
);

const result = renderHook(
(props: Omit<IntegerFieldConfig, 'name'>) =>
useIntegerField({
name: formBag.result.current.paths.test,
...props,
}),
{
wrapper: ({ children }) => (
<ReactiveFormProvider formBag={formBag.result.current}>
<IntegerFieldI18nContextProvider i18n={i18n}>{children}</IntegerFieldI18nContextProvider>
</ReactiveFormProvider>
),
initialProps,
},
);

return [result, formBag] as const;
};

describe('Integer field', () => {
it('Should format initial value correctly', () => {
const [{ result }] = renderUseIntegerField();

expect(result.current.text).toBe('0');
expect(result.current.value).toBe(0);
});

it('Should set default error in case of conversion error and clear it afterwards', async () => {
const [{ result }] = renderUseIntegerField();

act(() => {
result.current.onTextChange('0a');
});

await waitFor(() => {
expect(result.current.value).toBe(0);
expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput);
});

act(() => {
result.current.onTextChange('a0');
});

await waitFor(() => {
expect(result.current.value).toBe(0);
expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.invalidInput);
});

act(() => {
result.current.onTextChange('1');
});

await waitFor(() => {
expect(result.current.value).toBe(1);
expect(result.current.meta.error?.$error).toBeUndefined();
});
});

it('Should set default error if field is required and empty', async () => {
const [{ result }] = renderUseIntegerField({ required: true });

act(() => {
result.current.control.setValue(null);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.required);
});
});

it('Should set default error if field value is less than min', async () => {
const [{ result }] = renderUseIntegerField({ min: 0 });

act(() => {
result.current.control.setValue(-1);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.minValue(0));
});

act(() => {
result.current.control.setValue(0);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBeUndefined();
});
});

it('Should set default error if field value is more than max', async () => {
const [{ result }] = renderUseIntegerField({ max: 0 });

act(() => {
result.current.control.setValue(1);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultIntegerFieldI18n.maxValue(0));
});

act(() => {
result.current.control.setValue(0);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBeUndefined();
});
});

it('Should set custom error in case of conversion error and clear it afterwards', async () => {
const [{ result }] = renderUseIntegerField({
i18n: {
invalidInput: 'custom',
},
});

act(() => {
result.current.onTextChange('0a');
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe('custom');
});

act(() => {
result.current.onTextChange('a0');
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe('custom');
});

act(() => {
result.current.onTextChange('0');
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBeUndefined();
});
});

it('Should set custom error if field is required and empty', async () => {
const [{ result }] = renderUseIntegerField({
required: true,
i18n: {
required: 'custom',
},
});

act(() => {
result.current.control.setValue(null);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe('custom');
});
});

it('Should set custom error if field value is less than min', async () => {
const minValue = jest.fn(() => 'custom');

const [{ result }] = renderUseIntegerField({
min: 0,
i18n: {
minValue,
},
});

act(() => {
result.current.control.setValue(-1);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe('custom');
expect(minValue).toBeCalledWith(0);
});
});

it('Should set custom error if field value is more than max', async () => {
const maxValue = jest.fn(() => 'custom');

const [{ result }] = renderUseIntegerField({
max: 0,
i18n: {
maxValue,
},
});

act(() => {
result.current.control.setValue(1);
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe('custom');
expect(maxValue).toBeCalledWith(0);
});
});

it('Should be able to format integer differently', () => {
const format = jest.fn(() => 'custom');
const initialValue = 42;
const [{ result }] = renderUseIntegerField({ format, initialValue });

expect(result.current.text).toBe('custom');
expect(format).toBeCalledWith(initialValue);
});

it('Should call custom parse function', async () => {
const parse = jest.fn();

const [{ result }] = renderUseIntegerField({ parse });

await act(() => {
result.current.onTextChange('0');
});

await waitFor(() => {
expect(parse).toBeCalledWith('0');
});
});
});
Loading