diff --git a/.changeset/chilly-clocks-train.md b/.changeset/chilly-clocks-train.md new file mode 100644 index 00000000..b6950e3f --- /dev/null +++ b/.changeset/chilly-clocks-train.md @@ -0,0 +1,5 @@ +--- +'@reactive-forms/x': patch +--- + +Created useStringField hook diff --git a/packages/x/src/StringFieldI18n.tsx b/packages/x/src/StringFieldI18n.tsx new file mode 100644 index 00000000..a62f3f46 --- /dev/null +++ b/packages/x/src/StringFieldI18n.tsx @@ -0,0 +1,26 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +export type StringFieldI18n = { + required: string; + minLength: (length: number) => string; + maxLength: (length: number) => string; +}; + +export const defaultStringFieldI18n: StringFieldI18n = { + required: 'Field is required', + minLength: (minLength: number) => `String should not include less than ${minLength} character(s)`, + maxLength: (maxLength: number) => `String should not include more than ${maxLength} character(s)`, +}; + +export const StringFieldI18nContext = createContext(defaultStringFieldI18n); + +export type StringFieldI18nContextProviderProps = PropsWithChildren<{ i18n?: Partial }>; + +export const StringFieldI18nContextProvider = ({ i18n, children }: StringFieldI18nContextProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/x/src/useStringField.ts b/packages/x/src/useStringField.ts new file mode 100644 index 00000000..fdf49520 --- /dev/null +++ b/packages/x/src/useStringField.ts @@ -0,0 +1,56 @@ +import { useCallback, useContext } from 'react'; +import { FieldConfig, FieldContext, useField, useFieldValidator } from '@reactive-forms/core'; + +import { StringFieldI18nContext } from './StringFieldI18n'; + +export type StringFieldConfig = FieldConfig & { + required?: boolean; + minLength?: number; + maxLength?: number; +}; + +export type StringFieldBag = FieldContext & { + onBlur: () => void; +}; + +export const useStringField = ({ name, validator, schema, required, maxLength, minLength }: StringFieldConfig) => { + const fieldBag = useField({ name, validator, schema }); + + const { + control: { setTouched }, + } = fieldBag; + + const i18n = useContext(StringFieldI18nContext); + + useFieldValidator({ + name, + validator: (value: string | undefined | null) => { + const isValueEmpty = !value || value.trim().length === 0; + + if (required && isValueEmpty) { + return i18n.required; + } + + const valueLength = value?.length ?? 0; + + if (typeof minLength === 'number' && valueLength < minLength) { + return i18n.minLength(minLength); + } + + if (typeof maxLength === 'number' && valueLength > maxLength) { + return i18n.maxLength(maxLength); + } + + return undefined; + }, + }); + + const onBlur = useCallback(() => { + setTouched({ $touched: true }); + }, [setTouched]); + + return { + onBlur, + ...fieldBag, + }; +}; diff --git a/packages/x/tests/useStringField.test.tsx b/packages/x/tests/useStringField.test.tsx new file mode 100644 index 00000000..bf296ea9 --- /dev/null +++ b/packages/x/tests/useStringField.test.tsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { defaultStringFieldI18n, StringFieldI18n, StringFieldI18nContextProvider } from '../src/StringFieldI18n'; +import { StringFieldConfig, useStringField } from '../src/useStringField'; + +type Config = Omit & { + initialValue?: string; + i18n?: Partial; +}; + +const renderUseStringField = (config: Config = {}) => { + const { initialValue = '', i18n, ...initialProps } = config; + + const formBag = renderHook(() => + useForm({ + initialValues: { + test: initialValue, + }, + }), + ); + + const stringFieldBag = renderHook( + (props: Omit) => + useStringField({ + name: formBag.result.current.paths.test, + ...props, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps, + }, + ); + + return [stringFieldBag, formBag] as const; +}; + +describe('String field', () => { + it('Should set touched=true on blur', async () => { + const [{ result }] = renderUseStringField(); + + await act(() => { + result.current.onBlur(); + }); + + await waitFor(() => { + expect(result.current.meta.touched?.$touched).toBeTruthy(); + }); + }); + + it('Should set default error if field is required and empty', async () => { + const [{ result }] = renderUseStringField({ required: true }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(undefined); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(''); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(' '); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.required); + }); + + act(() => { + result.current.control.setValue('a'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if value is longer than maxLength', async () => { + const [{ result }] = renderUseStringField({ maxLength: 3 }); + + act(() => { + result.current.control.setValue('aaaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.maxLength(3)); + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set default error if value is shorter than minLength', async () => { + const [{ result }] = renderUseStringField({ minLength: 3 }); + + act(() => { + result.current.control.setValue('aa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultStringFieldI18n.minLength(3)); + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result }] = renderUseStringField({ + required: true, + i18n: { + required: 'custom', + }, + }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.control.setValue(undefined); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.control.setValue(''); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.control.setValue(' '); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.control.setValue('a'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if value is longer than maxLength', async () => { + const [{ result }] = renderUseStringField({ + maxLength: 3, + i18n: { + maxLength: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + act(() => { + result.current.control.setValue('aaaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if value is longer than maxLength (with callback)', async () => { + const callback = jest.fn(() => 'custom'); + const [{ result }] = renderUseStringField({ + maxLength: 3, + i18n: { + maxLength: callback, + }, + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + act(() => { + result.current.control.setValue('aaaa'); + }); + + await waitFor(() => { + expect(callback).toBeCalledWith(3); + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if value is shorter than minLength', async () => { + const [{ result }] = renderUseStringField({ + minLength: 3, + i18n: { + minLength: () => 'custom', + }, + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + act(() => { + result.current.control.setValue('aa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); + + it('Should set custom error if value is shorter than minLength', async () => { + const callback = jest.fn(() => 'custom'); + const [{ result }] = renderUseStringField({ + minLength: 3, + i18n: { + minLength: callback, + }, + }); + + act(() => { + result.current.control.setValue('aaa'); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + + act(() => { + result.current.control.setValue('aa'); + }); + + await waitFor(() => { + expect(callback).toBeCalledWith(3); + expect(result.current.meta.error?.$error).toBe('custom'); + }); + }); +});