diff --git a/.changeset/stale-cars-heal.md b/.changeset/stale-cars-heal.md new file mode 100644 index 00000000..fea90e02 --- /dev/null +++ b/.changeset/stale-cars-heal.md @@ -0,0 +1,5 @@ +--- +'@reactive-forms/x': minor +--- + +Created useBooleanField hook diff --git a/packages/x/src/BooleanFieldI18n.tsx b/packages/x/src/BooleanFieldI18n.tsx new file mode 100644 index 00000000..da548842 --- /dev/null +++ b/packages/x/src/BooleanFieldI18n.tsx @@ -0,0 +1,22 @@ +import React, { createContext, PropsWithChildren } from 'react'; +import merge from 'lodash/merge'; + +export type BooleanFieldI18n = { + required: string; +}; + +export const defaultBooleanFieldI18n: BooleanFieldI18n = { + required: 'Field is required', +}; + +export const BooleanFieldI18nContext = createContext(defaultBooleanFieldI18n); + +export type BooleanFieldI18nContextProviderProps = PropsWithChildren<{ i18n?: Partial }>; + +export const BooleanFieldI18nContextProvider = ({ i18n, children }: BooleanFieldI18nContextProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/x/src/useBooleanField.ts b/packages/x/src/useBooleanField.ts new file mode 100644 index 00000000..52575386 --- /dev/null +++ b/packages/x/src/useBooleanField.ts @@ -0,0 +1,42 @@ +import { useContext } from 'react'; +import { FieldConfig, FieldContext, useField, useFieldValidator } from '@reactive-forms/core'; + +import { BooleanFieldI18nContext } from './BooleanFieldI18n'; + +export type BooleanFieldConfig = FieldConfig & { + required?: boolean; +}; + +export type BooleanFieldBag = FieldContext & { + onBlur: () => void; +}; + +export const useBooleanField = ({ required, ...config }: BooleanFieldConfig) => { + const fieldBag = useField(config); + + const { + control: { setTouched }, + } = fieldBag; + + const i18n = useContext(BooleanFieldI18nContext); + + const onBlur = () => { + setTouched({ $touched: true }); + }; + + useFieldValidator({ + name: config.name, + validator: (value) => { + if (required && !value) { + return i18n.required; + } + + return undefined; + }, + }); + + return { + ...fieldBag, + onBlur, + }; +}; diff --git a/packages/x/tests/useBooleanField.test.tsx b/packages/x/tests/useBooleanField.test.tsx new file mode 100644 index 00000000..57dabc51 --- /dev/null +++ b/packages/x/tests/useBooleanField.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { ReactiveFormProvider, useForm } from '@reactive-forms/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { BooleanFieldI18n, BooleanFieldI18nContextProvider, defaultBooleanFieldI18n } from '../src/BooleanFieldI18n'; +import { BooleanFieldConfig, useBooleanField } from '../src/useBooleanField'; + +type Config = Omit & { + initialValue?: boolean; + i18n?: Partial; +}; + +const renderUseBooleanField = (config: Config = {}) => { + const { initialValue = false, i18n, ...initialProps } = config; + + const formBag = renderHook(() => + useForm({ + initialValues: { + test: initialValue, + }, + }), + ); + + const stringFieldBag = renderHook( + (props: Omit) => + useBooleanField({ + name: formBag.result.current.paths.test, + ...props, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps, + }, + ); + + return [stringFieldBag, formBag] as const; +}; + +describe('Boolean field', () => { + it('Should set touched=true on blur', async () => { + const [{ result }] = renderUseBooleanField(); + + expect(result.current.meta.touched?.$touched).toBeFalsy(); + + 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 }] = renderUseBooleanField({ required: true }); + + act(() => { + result.current.control.setValue(null); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultBooleanFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(undefined); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultBooleanFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(false); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe(defaultBooleanFieldI18n.required); + }); + + act(() => { + result.current.control.setValue(true); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); + + it('Should set custom error if field is required and empty', async () => { + const [{ result }] = renderUseBooleanField({ + 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(false); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBe('custom'); + }); + + act(() => { + result.current.control.setValue(true); + }); + + await waitFor(() => { + expect(result.current.meta.error?.$error).toBeUndefined(); + }); + }); +});