From ddd45e29745b73e7fe1684e197dcff598b375644 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 3 Nov 2023 19:34:16 +0100 Subject: feat(hooks): add an useForm hook * add two "sub"-hooks: useFormValues and useFormSubmit (that can be used independently) * handle initial data * handle custom submit callback * handle data validation * handle submit status --- src/utils/hooks/use-form/use-form-submit/index.ts | 1 + .../use-form-submit/use-form-submit.test.ts | 163 +++++++++++++++++++++ .../use-form/use-form-submit/use-form-submit.ts | 122 +++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/utils/hooks/use-form/use-form-submit/index.ts create mode 100644 src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts create mode 100644 src/utils/hooks/use-form/use-form-submit/use-form-submit.ts (limited to 'src/utils/hooks/use-form/use-form-submit') 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; + +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 => { + 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 => { + 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 => { + 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 = { + /** + * A callback to handle submit validation. + */ + validator: DataValidator; + /** + * The messages to use on failure or success. + */ + messages: Partial; +}; + +export type FormSubmitHandler = ( + data: T +) => Maybe> | Promise>>; + +export type FormSubmitStatus = 'IDLE' | 'PENDING' | 'FAILED' | 'SUCCEEDED'; + +export type FormHandlers> = { + /** + * 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; +}; + +export type UseFormSubmitReturn = { + /** + * The message to use on submit failure or success. + */ + messages: Nullable>; + /** + * A method to handle form submit. + * + * @param {FormEvent} e - The event. + * @returns {Promise} + */ + submit: (e: FormEvent) => Promise; + /** + * 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>} handlers - The submit handlers. + * @returns {UseFormSubmitReturn} A submit method, the status and messages. + */ +export const useFormSubmit = >( + data: T, + handlers?: Partial> +): UseFormSubmitReturn => { + const { onFailure, onSuccess, submit: submitHandler } = handlers ?? {}; + const [messages, setMessages] = + useState>>(null); + const [submitStatus, setSubmitStatus] = useState('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) => { + e.preventDefault(); + setMessages(null); + setSubmitStatus('PENDING'); + + return handleSubmit(); + }, + [handleSubmit] + ); + + return { messages, submit, submitStatus }; +}; -- cgit v1.2.3