Skip to content

Commit

Permalink
Extracted DecimalFieldI18nContext
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexShukel committed Sep 12, 2023
1 parent 581c7cc commit a0fa8d2
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 77 deletions.
36 changes: 36 additions & 0 deletions packages/x/src/DecimalFieldI18n.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { createContext, PropsWithChildren } from 'react';
import merge from 'lodash/merge';

export const defaultFormat = (value: number | null | undefined, precision: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '';
}

return value.toFixed(precision).toString();
};

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

export const defaultDecimalFieldI18n: DecimalFieldI18n = {
required: 'Field is required',
invalidInput: 'Must be decimal',
minValue: (min: number, precision: number) => `Value should not be less than ${defaultFormat(min, precision)}`,
maxValue: (max: number, precision: number) => `Value should not be greater than ${defaultFormat(max, precision)}`,
};

export const DecimalFieldI18nContext = createContext<DecimalFieldI18n>(defaultDecimalFieldI18n);

export type DecimalFieldI18nContextProviderProps = PropsWithChildren<{ i18n?: Partial<DecimalFieldI18n> }>;

export const DecimalFieldI18nContextProvider = ({ i18n, children }: DecimalFieldI18nContextProviderProps) => {
return (
<DecimalFieldI18nContext.Provider value={merge(defaultDecimalFieldI18n, i18n)}>
{children}
</DecimalFieldI18nContext.Provider>
);
};
70 changes: 15 additions & 55 deletions packages/x/src/useDecimalField.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { FieldConfig, useFieldValidator } from '@reactive-forms/core';
import { isFunction, isNil } from 'lodash';

import { DecimalFieldI18nContext, defaultFormat } from './DecimalFieldI18n';
import { ConversionError, ConverterFieldBag, useConverterField } from './useConverterField';

const DECIMAL_REGEX = /^\d*\.?\d*$/;
export const defaultPrecision = 2;

export const defaultFormat = (value: number | null | undefined, precision: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '';
}

return value.toFixed(precision).toString();
};

export const defaultRequiredError = 'Field is required';
export const defaultInvalidInputError = 'Must be decimal';
export const defaultMinValueError = (min: number, precision: number) =>
`Value should not be less than ${defaultFormat(min, precision)}`;
export const defaultMaxValueError = (max: number, precision: number) =>
`Value should not be more than ${defaultFormat(max, precision)}`;

export type ErrorTuple<T> = [value: T, message: string | ((value: T) => string)];

export type DecimalFieldConfig = FieldConfig<number | null | undefined> & {
required?: boolean | string;
invalidInput?: string;
min?: number | ErrorTuple<number>;
max?: number | ErrorTuple<number>;
required?: boolean;
min?: number;
max?: number;

format?: (value: number | null | undefined, precision: number) => string;
parse?: (text: string) => number;
Expand All @@ -43,13 +25,14 @@ export const useDecimalField = ({
validator,
schema,
required,
invalidInput,
min,
max,
format,
parse,
precision = defaultPrecision,
}: DecimalFieldConfig): DecimalFieldBag => {
const i18n = useContext(DecimalFieldI18nContext);

const defaultParse = useCallback(
(text: string) => {
text = text.trim();
Expand All @@ -58,26 +41,19 @@ export const useDecimalField = ({
return null;
}

const parseError = invalidInput ?? defaultInvalidInputError;

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

const value = Number.parseFloat(text);

if (Number.isNaN(value)) {
// "." is valid decimal number zero, however Number.parseFloat returns NaN
if (text === '.') {
return 0;
}

throw new ConversionError(parseError);
throw new ConversionError(i18n.invalidInput);
}

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

const formatValue = useCallback(
Expand All @@ -99,35 +75,19 @@ export const useDecimalField = ({
name,
validator: (value) => {
if (required && typeof value !== 'number') {
return required === true ? defaultRequiredError : required;
return i18n.required;
}

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

if (!isNil(min)) {
if (Array.isArray(min)) {
const [minValue, message] = min;

if (value < minValue) {
return isFunction(message) ? message(value) : message;
}
} else if (value < min) {
return defaultMinValueError(min, precision);
}
if (typeof min === 'number' && value < min) {
return i18n.minValue(min, precision);
}

if (!isNil(max)) {
if (Array.isArray(max)) {
const [maxValue, message] = max;

if (value > maxValue) {
return isFunction(message) ? message(value) : message;
}
} else if (value > max) {
return defaultMaxValueError(max, precision);
}
if (typeof max === 'number' && value > max) {
return i18n.maxValue(max, precision);
}

return undefined;
Expand Down
55 changes: 33 additions & 22 deletions packages/x/tests/useDecimalField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,20 @@ import { ReactiveFormProvider, useForm } from '@reactive-forms/core';
import { act, renderHook, waitFor } from '@testing-library/react';

import {
DecimalFieldConfig,
DecimalFieldI18n,
DecimalFieldI18nContextProvider,
defaultDecimalFieldI18n,
defaultFormat,
defaultInvalidInputError,
defaultMaxValueError,
defaultMinValueError,
defaultPrecision,
defaultRequiredError,
useDecimalField,
} from '../src/useDecimalField';
} from '../src/DecimalFieldI18n';
import { DecimalFieldConfig, defaultPrecision, useDecimalField } from '../src/useDecimalField';

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

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

const formBag = renderHook(() =>
useForm({
Expand All @@ -36,7 +34,9 @@ const renderUseDecimalField = (config: Config = {}) => {
}),
{
wrapper: ({ children }) => (
<ReactiveFormProvider formBag={formBag.result.current}>{children}</ReactiveFormProvider>
<ReactiveFormProvider formBag={formBag.result.current}>
<DecimalFieldI18nContextProvider i18n={i18n}>{children}</DecimalFieldI18nContextProvider>
</ReactiveFormProvider>
),
initialProps,
},
Expand All @@ -61,23 +61,23 @@ describe('Decimal field', () => {
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError);
expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput);
});

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

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError);
expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput);
});

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

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultInvalidInputError);
expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.invalidInput);
});

await act(() => {
Expand Down Expand Up @@ -113,8 +113,8 @@ describe('Decimal field', () => {
});

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

await act(() => {
Expand Down Expand Up @@ -153,7 +153,7 @@ describe('Decimal field', () => {
});

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

Expand All @@ -165,7 +165,7 @@ describe('Decimal field', () => {
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultMinValueError(0.5, defaultPrecision));
expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.minValue(0.5, defaultPrecision));
});

act(() => {
Expand All @@ -185,7 +185,7 @@ describe('Decimal field', () => {
});

await waitFor(() => {
expect(result.current.meta.error?.$error).toBe(defaultMaxValueError(0.5, defaultPrecision));
expect(result.current.meta.error?.$error).toBe(defaultDecimalFieldI18n.maxValue(0.5, defaultPrecision));
});

act(() => {
Expand All @@ -199,7 +199,9 @@ describe('Decimal field', () => {

it('Should set custom conversion error correctly', async () => {
const [{ result }] = renderUseDecimalField({
invalidInput: 'custom',
i18n: {
invalidInput: 'custom',
},
});

await act(() => {
Expand Down Expand Up @@ -229,7 +231,10 @@ describe('Decimal field', () => {

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

act(() => {
Expand All @@ -243,7 +248,10 @@ describe('Decimal field', () => {

it('Should set custom error if field value is less than min', async () => {
const [{ result }] = renderUseDecimalField({
min: [0.5, 'custom'],
min: 0.5,
i18n: {
minValue: () => 'custom',
},
});

act(() => {
Expand All @@ -257,7 +265,10 @@ describe('Decimal field', () => {

it('Should set custom error if field value is more than max', async () => {
const [{ result }] = renderUseDecimalField({
max: [0.5, 'custom'],
max: 0.5,
i18n: {
maxValue: () => 'custom',
},
});

act(() => {
Expand Down

0 comments on commit a0fa8d2

Please sign in to comment.