diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/types/generics.ts | 5 | ||||
| -rw-r--r-- | src/types/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/index.ts | 3 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-submit/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts | 163 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-submit/use-form-submit.ts | 122 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-values/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-values/use-form-values.test.ts | 69 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form-values/use-form-values.ts | 69 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form.test.ts | 225 | ||||
| -rw-r--r-- | src/utils/hooks/use-form/use-form.ts | 134 | 
12 files changed, 794 insertions, 0 deletions
| diff --git a/src/types/generics.ts b/src/types/generics.ts new file mode 100644 index 0000000..5377c54 --- /dev/null +++ b/src/types/generics.ts @@ -0,0 +1,5 @@ +export type Maybe<T> = T | undefined; + +export type Nullable<T> = T | null; + +export type DataValidator<T> = (data: T) => boolean | Promise<boolean>; diff --git a/src/types/index.ts b/src/types/index.ts index c9820db..e2f0f55 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@  export * from './app'; +export * from './generics';  export * from './graphql';  export * from './mdx';  export * from './raw-data'; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 9181b6a..9cc2b0f 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -5,6 +5,7 @@ export * from './use-boolean';  export * from './use-breadcrumb';  export * from './use-comments';  export * from './use-data-from-api'; +export * from './use-form';  export * from './use-github-api';  export * from './use-headings-tree';  export * from './use-is-mounted'; diff --git a/src/utils/hooks/use-form/index.ts b/src/utils/hooks/use-form/index.ts new file mode 100644 index 0000000..9febc8b --- /dev/null +++ b/src/utils/hooks/use-form/index.ts @@ -0,0 +1,3 @@ +export * from './use-form'; +export * from './use-form-submit'; +export * from './use-form-values'; diff --git a/src/utils/hooks/use-form/use-form-submit/index.ts b/src/utils/hooks/use-form/use-form-submit/index.ts new file mode 100644 index 0000000..f7f5bdf --- /dev/null +++ b/src/utils/hooks/use-form/use-form-submit/index.ts @@ -0,0 +1 @@ +export * from './use-form-submit'; diff --git a/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts b/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts new file mode 100644 index 0000000..cb0da16 --- /dev/null +++ b/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import type { FormEvent } from 'react'; +import { type FormSubmitValidation, useFormSubmit } from './use-form-submit'; + +const generateSubmitEvent = () => +  new Event('submit', { +    bubbles: true, +    cancelable: true, +  }) as unknown as FormEvent<HTMLFormElement>; + +describe('useFormSubmit', () => { +  const data = { foo: 'tempore', bar: false, baz: 42 }; +  const messages = { error: 'Error', success: 'Success' }; + +  it('can submit the provided data', async () => { +    const { result } = renderHook(() => useFormSubmit(data)); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(4); + +    expect(result.current.messages).toBeNull(); +    expect(result.current.submitStatus).toBe('IDLE'); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.messages).toBeNull(); +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +  }); + +  it('can use a callback to handle submit', async () => { +    const callback = jest.fn((_data) => undefined); +    const { result } = renderHook(() => +      useFormSubmit(data, { submit: callback }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(5); + +    expect(callback).not.toHaveBeenCalled(); +    expect(result.current.messages).toBeNull(); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(callback).toHaveBeenCalledTimes(1); +    expect(callback).toHaveBeenCalledWith(data); +    expect(result.current.messages).toBeNull(); +  }); + +  it('can use a callback that fails validating data on submit', async () => { +    const callback = jest.fn( +      (values: typeof data): FormSubmitValidation<typeof data> => { +        return { +          messages, +          validator: () => values.bar, +        }; +      } +    ); +    const { result } = renderHook(() => +      useFormSubmit(data, { submit: callback }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(6); + +    expect(callback).not.toHaveBeenCalled(); +    expect(result.current.messages).toBeNull(); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(callback).toHaveBeenCalledTimes(1); +    expect(callback).toHaveBeenCalledWith(data); +    expect(result.current.submitStatus).toBe('FAILED'); +    expect(result.current.messages).toBe(messages); +  }); + +  it('can use a callback that succeeds validating data on submit', async () => { +    const callback = jest.fn( +      (values: typeof data): FormSubmitValidation<typeof data> => { +        return { +          messages, +          validator: () => !values.bar, +        }; +      } +    ); +    const { result } = renderHook(() => +      useFormSubmit(data, { submit: callback }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(6); + +    expect(callback).not.toHaveBeenCalled(); +    expect(result.current.messages).toBeNull(); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(callback).toHaveBeenCalledTimes(1); +    expect(callback).toHaveBeenCalledWith(data); +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +    expect(result.current.messages).toBe(messages); +  }); + +  it('can call an onSuccess callback on success', async () => { +    const callback = jest.fn(); +    const { result } = renderHook(() => +      useFormSubmit(data, { onSuccess: callback }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(6); + +    expect(callback).not.toHaveBeenCalled(); +    expect(result.current.messages).toBeNull(); +    expect(result.current.submitStatus).toBe('IDLE'); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.messages).toBeNull(); +    expect(callback).toHaveBeenCalledTimes(1); +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +  }); + +  it('can call an onFailure callback on failure', async () => { +    const handlers = { +      onFailure: jest.fn(), +      submit: jest.fn( +        (values: typeof data): FormSubmitValidation<typeof data> => { +          return { +            messages, +            validator: () => values.bar, +          }; +        } +      ), +    }; +    const { result } = renderHook(() => useFormSubmit(data, handlers)); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(6); + +    expect(handlers.onFailure).not.toHaveBeenCalled(); +    expect(result.current.messages).toBeNull(); +    expect(result.current.submitStatus).toBe('IDLE'); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.messages).toBe(messages); +    expect(handlers.onFailure).toHaveBeenCalledTimes(1); +    expect(result.current.submitStatus).toBe('FAILED'); +  }); +}); diff --git a/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts b/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts new file mode 100644 index 0000000..8d02395 --- /dev/null +++ b/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts @@ -0,0 +1,122 @@ +import { useCallback, useState, type FormEvent } from 'react'; +import type { DataValidator, Maybe, Nullable } from '../../../../types'; + +export type FormSubmitMessages = { +  /** +   * The message to use on error. +   */ +  error: string; +  /** +   * The message to use on success. +   */ +  success: string; +}; + +export type FormSubmitValidation<T> = { +  /** +   * A callback to handle submit validation. +   */ +  validator: DataValidator<T>; +  /** +   * The messages to use on failure or success. +   */ +  messages: Partial<FormSubmitMessages>; +}; + +export type FormSubmitHandler<T> = ( +  data: T +) => Maybe<FormSubmitValidation<T>> | Promise<Maybe<FormSubmitValidation<T>>>; + +export type FormSubmitStatus = 'IDLE' | 'PENDING' | 'FAILED' | 'SUCCEEDED'; + +export type FormHandlers<T extends Record<string, unknown>> = { +  /** +   * A callback function to handle submit failure. +   */ +  onFailure: () => void; +  /** +   * A callback function to handle submit success. +   */ +  onSuccess: () => void; +  /** +   * A callback function to handle submit. +   */ +  submit: FormSubmitHandler<T>; +}; + +export type UseFormSubmitReturn = { +  /** +   * The message to use on submit failure or success. +   */ +  messages: Nullable<Partial<FormSubmitMessages>>; +  /** +   * A method to handle form submit. +   * +   * @param {FormEvent<HTMLFormElement>} e - The event. +   * @returns {Promise<void>} +   */ +  submit: (e: FormEvent<HTMLFormElement>) => Promise<void>; +  /** +   * The submit status. +   */ +  submitStatus: FormSubmitStatus; +}; + +/** + * React hook to handle form submit. + * + * @template {object} T - The object keys should match the fields name. + * @param {T} data - The form values. + * @param {Partial<FormHandlers<T>>} handlers - The submit handlers. + * @returns {UseFormSubmitReturn} A submit method, the status and messages. + */ +export const useFormSubmit = <T extends Record<string, unknown>>( +  data: T, +  handlers?: Partial<FormHandlers<T>> +): UseFormSubmitReturn => { +  const { onFailure, onSuccess, submit: submitHandler } = handlers ?? {}; +  const [messages, setMessages] = +    useState<Nullable<Partial<FormSubmitMessages>>>(null); +  const [submitStatus, setSubmitStatus] = useState<FormSubmitStatus>('IDLE'); + +  const handleFailure = useCallback(() => { +    setSubmitStatus('FAILED'); +    if (onFailure) onFailure(); +  }, [onFailure]); + +  const handleSuccess = useCallback(() => { +    setSubmitStatus('SUCCEEDED'); +    if (onSuccess) onSuccess(); +  }, [onSuccess]); + +  const handleSubmit = useCallback(async () => { +    const submitResult = submitHandler ? await submitHandler(data) : undefined; + +    if (!submitResult) { +      handleSuccess(); +      return; +    } + +    setMessages(submitResult.messages); + +    const isSuccess = submitResult.validator(data); + +    setSubmitStatus(isSuccess ? 'SUCCEEDED' : 'FAILED'); + +    if (isSuccess) handleSuccess(); +    else handleFailure(); +  }, [data, handleFailure, handleSuccess, submitHandler]); + +  const submit = useCallback( +    async (e: FormEvent<HTMLFormElement>) => { +      e.preventDefault(); +      setMessages(null); +      setSubmitStatus('PENDING'); + +      return handleSubmit(); +    }, +    [handleSubmit] +  ); + +  return { messages, submit, submitStatus }; +}; diff --git a/src/utils/hooks/use-form/use-form-values/index.ts b/src/utils/hooks/use-form/use-form-values/index.ts new file mode 100644 index 0000000..664a862 --- /dev/null +++ b/src/utils/hooks/use-form/use-form-values/index.ts @@ -0,0 +1 @@ +export * from './use-form-values'; diff --git a/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts b/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts new file mode 100644 index 0000000..f86d910 --- /dev/null +++ b/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import type { ChangeEvent } from 'react'; +import { useFormValues } from './use-form-values'; + +/** + * Generate a new change event. + * + * @param {string} name - The field name. + * @param {unknown} value - The new value of the field. + * @returns {ChangeEvent<HTMLInputElement>} The event. + */ +const generateChangeEvent = (name: string, value: unknown, type = 'text') => { +  const ev = new Event('change'); +  Object.defineProperty(ev, 'target', { +    value: { +      checked: type === 'checkbox' || type === 'radio' ? value : undefined, +      name, +      type, +      value: type === 'checkbox' || type === 'radio' ? undefined : value, +    }, +    writable: false, +  }); + +  return ev as unknown as ChangeEvent<HTMLInputElement>; +}; + +describe('useFormValues', () => { +  const initialValues = { +    foo: 'hello', +    bar: false, +  }; +  const newValues = { +    foo: 'world', +    bar: true, +  }; + +  it('can initialize the values', () => { +    const { result } = renderHook(() => useFormValues(initialValues)); + +    expect(result.current.values.bar).toBe(initialValues.bar); +    expect(result.current.values.foo).toBe(initialValues.foo); +  }); + +  it('can update and reset the values', () => { +    const { result } = renderHook(() => useFormValues(initialValues)); + +    act(() => { +      result.current.update( +        generateChangeEvent('bar', newValues.bar, 'checkbox') +      ); +    }); + +    expect(result.current.values.bar).toBe(newValues.bar); + +    act(() => { +      result.current.update(generateChangeEvent('foo', newValues.foo)); +    }); + +    expect(result.current.values.foo).toBe(newValues.foo); + +    act(() => { +      result.current.reset(); +    }); + +    expect(result.current.values.bar).toBe(initialValues.bar); +    expect(result.current.values.foo).toBe(initialValues.foo); +  }); +}); diff --git a/src/utils/hooks/use-form/use-form-values/use-form-values.ts b/src/utils/hooks/use-form/use-form-values/use-form-values.ts new file mode 100644 index 0000000..8a0962f --- /dev/null +++ b/src/utils/hooks/use-form/use-form-values/use-form-values.ts @@ -0,0 +1,69 @@ +import { +  type ChangeEventHandler, +  useCallback, +  useState, +  type ChangeEvent, +} from 'react'; + +const isBooleanField = ( +  target: EventTarget & (HTMLInputElement | HTMLTextAreaElement) +): target is EventTarget & HTMLInputElement => +  target.type === 'checkbox' || target.type === 'radio'; + +export type UseFormValuesReturn<T extends Record<string, unknown>> = { +  /** +   * A method to reset the fields to their initial values. +   * +   * @returns {void} +   */ +  reset: () => void; +  /** +   * A method to handle input or textarea update. +   * +   * @param {ChangeEvent<HTMLTextAreaElement | HTMLInputElement>} e - The event. +   * @returns {void} +   */ +  update: (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void; +  /** +   * The fields values. +   */ +  values: T; +}; + +/** + * React hook to handle form values update and reset. + * + * @template {object} T - The object keys should match the fields name. + * @param {T} initialValues - The fields initial values. + * @returns {UseFormValuesReturn<T>} An object with values and two methods. + */ +export const useFormValues = <T extends Record<string, unknown>>( +  initialValues: T +): UseFormValuesReturn<T> => { +  const [values, setValues] = useState(initialValues); + +  /** +   * Reset the field to their initial values. +   */ +  const reset = useCallback(() => { +    setValues(initialValues); +  }, [initialValues]); + +  /** +   * Handle input and textarea update. +   * +   * @param {ChangeEvent<HTMLTextAreaElement | HTMLInputElement>} e - The event. +   * @returns {void} +   */ +  const update: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = +    useCallback(({ target }) => { +      setValues((prevData) => { +        return { +          ...prevData, +          [target.name]: isBooleanField(target) ? target.checked : target.value, +        }; +      }); +    }, []); + +  return { values, reset, update }; +}; diff --git a/src/utils/hooks/use-form/use-form.test.ts b/src/utils/hooks/use-form/use-form.test.ts new file mode 100644 index 0000000..e2c30b5 --- /dev/null +++ b/src/utils/hooks/use-form/use-form.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import type { ChangeEvent, FormEvent } from 'react'; +import { useForm } from './use-form'; + +const generateSubmitEvent = () => +  new Event('submit', { +    bubbles: true, +    cancelable: true, +  }) as unknown as FormEvent<HTMLFormElement>; + +/** + * Generate a new change event. + * + * @param {string} name - The field name. + * @param {unknown} value - The new value of the field. + * @returns {ChangeEvent<HTMLInputElement>} The event. + */ +const generateChangeEvent = (name: string, value: unknown, type = 'text') => { +  const ev = new Event('change'); +  Object.defineProperty(ev, 'target', { +    value: { +      checked: type === 'checkbox' || type === 'radio' ? value : undefined, +      name, +      type, +      value: type === 'checkbox' || type === 'radio' ? undefined : value, +    }, +    writable: false, +  }); + +  return ev as unknown as ChangeEvent<HTMLInputElement>; +}; + +describe('useForm', () => { +  it('can initialize the data', () => { +    const initialValues = { +      foo: 'impedit', +      bar: 42, +    }; +    const { result } = renderHook(() => useForm({ initialValues })); + +    expect(result.current.values.bar).toBe(initialValues.bar); +    expect(result.current.values.foo).toBe(initialValues.foo); +  }); + +  it('can use a handler to validate the submit process', async () => { +    const data = { +      name: 'John', +    }; +    const submitHandler = jest.fn((_data) => undefined); +    const { result } = renderHook(() => +      useForm({ +        initialValues: data, +        submitHandler, +      }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(3); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.validationErrors).toBeNull(); +    expect(submitHandler).toHaveBeenCalled(); +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +  }); + +  it('can submit the data and reset to initial values', async () => { +    const data = { +      initial: { +        name: 'John', +      }, +      new: { +        name: 'Phoebe', +      }, +    }; +    const { result } = renderHook(() => +      useForm({ +        initialValues: data.initial, +      }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(3); + +    act(() => { +      result.current.update(generateChangeEvent('name', data.new.name)); +    }); + +    expect(result.current.values.name).toBe(data.new.name); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +    expect(result.current.values.name).toBe(data.initial.name); +  }); + +  it('can submit after validating the data', async () => { +    const data = { +      initial: { +        name: 'John', +      }, +      new: { +        name: 'Phoebe', +      }, +      errors: { +        name: 'Expect name to have at least 2 letters.', +      }, +    }; +    const submitHandler = jest.fn((_data) => undefined); +    const { result } = renderHook(() => +      useForm({ +        initialValues: data.initial, +        submitHandler, +        validations: { +          name: { +            error: data.errors.name, +            validator: (value) => value.length > 1, +          }, +        }, +      }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(4); + +    act(() => { +      result.current.update(generateChangeEvent('name', data.new.name)); +    }); + +    expect(result.current.values.name).toBe(data.new.name); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.validationErrors).toBeNull(); +    expect(submitHandler).toHaveBeenCalled(); +    expect(result.current.submitStatus).toBe('SUCCEEDED'); +  }); + +  it('can abort submit if data validation fails', async () => { +    const minAge = 18; +    const data = { +      initial: { +        name: 'H', +        age: 17, +      }, +      errors: { +        age: `Expect age to be at least ${minAge}.`, +        name: 'Expect name to have at least 2 letters.', +      }, +    }; +    const submitHandler = jest.fn((_data) => undefined); +    const { result } = renderHook(() => +      useForm({ +        initialValues: data.initial, +        submitHandler, +        validations: { +          age: { +            error: data.errors.age, +            validator: (value) => value >= minAge, +          }, +          name: { +            error: data.errors.name, +            validator: (value) => value.length > 1, +          }, +        }, +      }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(4); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.validationErrors?.age).toBe(data.errors.age); +    expect(result.current.validationErrors?.name).toBe(data.errors.name); +    expect(submitHandler).not.toHaveBeenCalled(); +    expect(result.current.submitStatus).toBe('FAILED'); +  }); + +  it('can partially validate the data before submit', async () => { +    const data = { +      initial: { +        name: 'H', +        age: 17, +      }, +      errors: { +        name: 'Expect name to have at least 2 letters.', +      }, +    }; +    const submitHandler = jest.fn((_data) => undefined); +    const { result } = renderHook(() => +      useForm({ +        initialValues: data.initial, +        submitHandler, +        validations: { +          name: { +            error: data.errors.name, +            validator: (value) => value.length > 1, +          }, +        }, +      }) +    ); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(4); + +    await act(async () => { +      await result.current.submit(generateSubmitEvent()); +    }); + +    expect(result.current.validationErrors?.age).toBeUndefined(); +    expect(result.current.validationErrors?.name).toBe(data.errors.name); +    expect(submitHandler).not.toHaveBeenCalled(); +    expect(result.current.submitStatus).toBe('FAILED'); +  }); +}); diff --git a/src/utils/hooks/use-form/use-form.ts b/src/utils/hooks/use-form/use-form.ts new file mode 100644 index 0000000..492f820 --- /dev/null +++ b/src/utils/hooks/use-form/use-form.ts @@ -0,0 +1,134 @@ +import { useCallback, useState } from 'react'; +import type { DataValidator, Nullable } from '../../../types'; +import { +  type FormSubmitHandler, +  useFormSubmit, +  type UseFormSubmitReturn, +} from './use-form-submit'; +import { type UseFormValuesReturn, useFormValues } from './use-form-values'; + +export type FormFieldValidation<T> = { +  /** +   * The error message. +   */ +  error: string; +  /** +   * A function to validate the field value. +   */ +  validator: DataValidator<T>; +}; + +export type FormValidations<T> = { +  [K in keyof T]: FormFieldValidation<T[K]>; +}; + +export type FormValidationErrors<T> = { +  [K in keyof T]?: string; +}; + +export type UseFormConfig<T> = { +  /** +   * The initial fields values. +   */ +  initialValues: T; +  /** +   * A function to handle submit. +   */ +  submitHandler?: FormSubmitHandler<T>; +  /** +   * An object with validator and error message to validate each field. +   */ +  validations?: Partial<FormValidations<T>>; +}; + +export type UseFormReturn<T extends Record<string, unknown>> = +  UseFormValuesReturn<T> & +    UseFormSubmitReturn & { +      /** +       * The validation error for each field. +       */ +      validationErrors: Nullable<FormValidationErrors<T>>; +    }; + +/** + * React hook to manage forms. + * + * @template {object} T - The object keys should match the fields name. + * @param {UseFormConfig<T>} config - The config. + * @returns {UseFormReturn<T>} The values, validations errors and form methods. + */ +export const useForm = <T extends Record<string, unknown>>({ +  initialValues, +  submitHandler, +  validations, +}: UseFormConfig<T>): UseFormReturn<T> => { +  const { values, reset, update } = useFormValues(initialValues); +  const [validationErrors, setValidationErrors] = +    useState<Nullable<FormValidationErrors<T>>>(null); + +  const areValuesValid = useCallback(async () => { +    if (!validations) return true; + +    const keys = Object.keys(values) as Extract<keyof T, string>[]; +    const validationPromises = keys.map(async (key) => { +      const value = values[key]; +      const field = validations[key]; + +      if (!field) return true; + +      const isValidField = await field.validator(value); + +      if (!isValidField) +        setValidationErrors((prevErr) => { +          const newErrors: FormValidationErrors<T> = prevErr +            ? { ...prevErr } +            : {}; + +          return { +            ...newErrors, +            [key]: field.error, +          }; +        }); + +      return isValidField; +    }); + +    const awaitedValidation = await Promise.all(validationPromises); + +    return awaitedValidation.every((isValid) => isValid); +  }, [validations, values]); + +  const handleSubmit = useCallback(async () => { +    setValidationErrors(null); + +    const isValid = await areValuesValid(); + +    if (isValid && submitHandler) return submitHandler(values); + +    return { +      validator: () => isValid, +      messages: { +        error: 'Has invalid values', +      }, +    }; +  }, [areValuesValid, submitHandler, values]); + +  const handleSuccess = useCallback(() => { +    reset(); +  }, [reset]); + +  const { messages, submit, submitStatus } = useFormSubmit(values, { +    onSuccess: handleSuccess, +    submit: handleSubmit, +  }); + +  return { +    messages, +    reset, +    submit, +    submitStatus, +    update, +    values, +    validationErrors, +  }; +}; | 
