diff --git a/src/GlobalFormsProvider/GlobalFormsContext.ts b/src/GlobalFormsProvider/GlobalFormsContext.ts new file mode 100644 index 00000000..1f15146e --- /dev/null +++ b/src/GlobalFormsProvider/GlobalFormsContext.ts @@ -0,0 +1,88 @@ +import React, { useCallback, useContext, useMemo } from 'react'; + +export type FormInfo = { name: string; order: number }; +type FormInfoObjectOrString = FormInfo | string; + +export type CallbackWithOrder = { + callback: () => Promise; + order: number; +}; + +export type GlobalFormsContextType = { + isSubmitting: boolean; + reset: () => void; + setResetCallback: (form: FormInfo, callback: () => void) => void; + setSubmitCallback: (form: FormInfo, callback: () => Promise) => void; + setSubmitting: (form: FormInfo, submitting: boolean) => void; + setSubmitSuccessful: (form: FormInfo, submitSuccessful: boolean) => void; + /** Returns true when all forms were submitted successfully. */ + submit: (additionalCallback?: CallbackWithOrder) => Promise; +}; + +export const GlobalFormsContext = React.createContext({ + isSubmitting: false, + reset: () => {}, + setResetCallback: () => {}, + setSubmitCallback: () => {}, + setSubmitting: () => {}, + setSubmitSuccessful: () => {}, + submit: () => Promise.resolve(false), +}); + +export function useGlobalForms() { + return useContext(GlobalFormsContext); +} + +export function normalizeFormInfo(form: FormInfoObjectOrString): FormInfo { + if (typeof form === 'string') { + return { name: form, order: 0 }; + } + + return form; +} + +export function useGlobalForm(form: FormInfoObjectOrString) { + const formInfo = useMemo(() => normalizeFormInfo(form), [form]); + + const { + setResetCallback, + setSubmitCallback, + setSubmitting, + setSubmitSuccessful, + } = useContext(GlobalFormsContext); + + const setResetCallbackWrapped = useCallback( + (callback: () => void) => { + setResetCallback(formInfo, callback); + }, + [formInfo, setResetCallback], + ); + + const setSubmitCallbackWrapped = useCallback( + (callback: () => Promise) => { + setSubmitCallback(formInfo, callback); + }, + [formInfo, setSubmitCallback], + ); + + const setSubmittingWrapped = useCallback( + (submitting: boolean) => { + setSubmitting(formInfo, submitting); + }, + [formInfo, setSubmitting], + ); + + const setSubmitSuccessfulWrapped = useCallback( + (submitSuccessful: boolean) => { + setSubmitSuccessful(formInfo, submitSuccessful); + }, + [formInfo, setSubmitSuccessful], + ); + + return { + setResetCallback: setResetCallbackWrapped, + setSubmitCallback: setSubmitCallbackWrapped, + setSubmitSuccessful: setSubmitSuccessfulWrapped, + setSubmitting: setSubmittingWrapped, + }; +} diff --git a/src/GlobalFormsProvider/GlobalFormsProvider.test.tsx b/src/GlobalFormsProvider/GlobalFormsProvider.test.tsx new file mode 100644 index 00000000..1469cdf2 --- /dev/null +++ b/src/GlobalFormsProvider/GlobalFormsProvider.test.tsx @@ -0,0 +1,179 @@ +import { + act, + renderHook, + RenderHookOptions, +} from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; + +import { useGlobalForms, useGlobalForm } from './GlobalFormsContext'; +import { GlobalFormsProvider } from './GlobalFormsProvider'; + +function createRenderHookOptions(): RenderHookOptions< + PropsWithChildren +> { + return { + wrapper: function Wrapper({ children }: PropsWithChildren) { + return {children}; + }, + }; +} + +describe('GlobalFormsProvider', () => { + it('finds one submitting form', () => { + const { result } = renderHook(() => { + const { isSubmitting } = useGlobalForms(); + const { setSubmitting: setSubmitting1 } = useGlobalForm('form1'); + const { setSubmitting: setSubmitting2 } = useGlobalForm('form2'); + + return { isSubmitting, setSubmitting1, setSubmitting2 }; + }, createRenderHookOptions()); + expect(result.error).toBeFalsy(); + expect(result.current.isSubmitting).toBeFalsy(); + + act(() => result.current.setSubmitting1(true)); + act(() => result.current.setSubmitting2(false)); + expect(result.current.isSubmitting).toBeTruthy(); + }); + + it('overrides submitting value', () => { + const { result } = renderHook(() => { + const { isSubmitting } = useGlobalForms(); + const { setSubmitting } = useGlobalForm('form'); + + return { isSubmitting, setSubmitting }; + }, createRenderHookOptions()); + expect(result.current.isSubmitting).toBeFalsy(); + + act(() => result.current.setSubmitting(true)); + expect(result.current.isSubmitting).toBeTruthy(); + + act(() => result.current.setSubmitting(false)); + expect(result.current.isSubmitting).toBeFalsy(); + }); + + it('calls all reset callbacks', () => { + const { result } = renderHook(() => { + const { reset } = useGlobalForms(); + const { setResetCallback: setResetCallback1 } = useGlobalForm('form1'); + const { setResetCallback: setResetCallback2 } = useGlobalForm('form2'); + + return { reset, setResetCallback1, setResetCallback2 }; + }, createRenderHookOptions()); + result.current.reset(); + + const reset1 = jest.fn(); + act(() => result.current.setResetCallback1(reset1)); + + const reset2 = jest.fn(); + act(() => result.current.setResetCallback2(reset2)); + + result.current.reset(); + expect(reset1).toHaveBeenCalledTimes(1); + expect(reset2).toHaveBeenCalledTimes(1); + + act(() => result.current.setResetCallback1(jest.fn())); + result.current.reset(); + expect(reset1).toHaveBeenCalledTimes(1); + expect(reset2).toHaveBeenCalledTimes(2); + }); + + it('overrides reset callback', () => { + const { result } = renderHook(() => { + const { reset } = useGlobalForms(); + const { setResetCallback } = useGlobalForm('form'); + + return { reset, setResetCallback }; + }, createRenderHookOptions()); + const resetOld = jest.fn(); + act(() => result.current.setResetCallback(resetOld)); + result.current.reset(); + expect(resetOld).toHaveBeenCalledTimes(1); + + const resetNew = jest.fn(); + act(() => result.current.setResetCallback(resetNew)); + result.current.reset(); + expect(resetOld).toHaveBeenCalledTimes(1); + expect(resetNew).toHaveBeenCalledTimes(1); + }); + + it('calls all submit callbacks', async () => { + const { result } = renderHook(() => { + const { submit } = useGlobalForms(); + const { setSubmitCallback: setSubmitCallback1 } = useGlobalForm('form1'); + const { setSubmitCallback: setSubmitCallback2 } = useGlobalForm('form2'); + + return { submit, setSubmitCallback1, setSubmitCallback2 }; + }, createRenderHookOptions()); + await expect(result.current.submit()).resolves.toBeTruthy(); + + const submit1 = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallback1(submit1)); + + const submit2 = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallback2(submit2)); + + await expect(result.current.submit()).resolves.toBeFalsy(); + expect(submit1).toHaveBeenCalledTimes(1); + expect(submit2).toHaveBeenCalledTimes(1); + }); + + it('overrides submit callback', async () => { + const { result } = renderHook(() => { + const { submit } = useGlobalForms(); + const { setSubmitCallback } = useGlobalForm('form'); + + return { submit, setSubmitCallback }; + }, createRenderHookOptions()); + + const submitOld = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallback(submitOld)); + + await expect(result.current.submit()).resolves.toBeFalsy(); + expect(submitOld).toHaveBeenCalledTimes(1); + + const submitNew = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallback(submitNew)); + + await expect(result.current.submit()).resolves.toBeFalsy(); + expect(submitOld).toHaveBeenCalledTimes(1); + expect(submitNew).toHaveBeenCalledTimes(1); + }); + + it('maintains submit order', async () => { + const { result } = renderHook(() => { + const { submit } = useGlobalForms(); + const { setSubmitCallback: setSubmitCallbackError } = useGlobalForm({ + name: 'form-error', + order: 2, + }); + const { setSubmitCallback: setSubmitCallbackFirst } = useGlobalForm({ + name: 'form-first', + order: 1, + }); + const { setSubmitCallback: setSubmitCallbackLast } = useGlobalForm({ + name: 'form-last', + order: 3, + }); + + return { + submit, + setSubmitCallbackError, + setSubmitCallbackFirst, + setSubmitCallbackLast, + }; + }, createRenderHookOptions()); + const submitError = jest.fn(() => Promise.reject()); + act(() => result.current.setSubmitCallbackError(submitError)); + + const submitFirst = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallbackFirst(submitFirst)); + + const submitLast = jest.fn(() => Promise.resolve()); + act(() => result.current.setSubmitCallbackLast(submitLast)); + + await expect(result.current.submit()).rejects.toBeFalsy(); + expect(submitFirst).toHaveBeenCalledTimes(1); + expect(submitError).toHaveBeenCalledTimes(1); + expect(submitLast).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/GlobalFormsProvider/GlobalFormsProvider.tsx b/src/GlobalFormsProvider/GlobalFormsProvider.tsx new file mode 100644 index 00000000..55aa1888 --- /dev/null +++ b/src/GlobalFormsProvider/GlobalFormsProvider.tsx @@ -0,0 +1,138 @@ +import { Maybe, callSequentially } from '@mll-lab/js-utils'; +import { sortBy } from 'lodash'; +import React, { + PropsWithChildren, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; + +import { + CallbackWithOrder, + GlobalFormsContext, + GlobalFormsContextType, +} from './GlobalFormsContext'; + +export type GlobalFormsProviderProps = PropsWithChildren; + +function findNextOrder( + callbacks: Maybe>, +): number { + if (!callbacks) { + return 1; + } + + const orders = Object.values(callbacks).map((callback) => callback.order); + if (orders.length === 0) { + return 1; + } + + return Math.max(...orders) + 1; +} + +export function GlobalFormsProvider({ children }: GlobalFormsProviderProps) { + const [submitCallbacks, setSubmitCallbacks] = + useState>(); + const [resetCallbacks, setResetCallbacks] = + useState void>>(); + const [submittings, setSubmittings] = useState>(); + const submitSuccessfulList = useRef>({}); + + const setSubmitCallback: GlobalFormsContextType['setSubmitCallback'] = + useCallback((form, callback) => { + setSubmitCallbacks((prevCallbacks) => { + const nextOrder = + form.order > 0 ? form.order : findNextOrder(prevCallbacks); + + return { + ...prevCallbacks, + [form.name]: { + callback, + order: nextOrder, + }, + }; + }); + }, []); + + const setResetCallback: GlobalFormsContextType['setResetCallback'] = + useCallback((form, callback) => { + setResetCallbacks((prevCallbacks) => ({ + ...prevCallbacks, + [form.name]: callback, + })); + }, []); + + const setSubmitSuccessful: GlobalFormsContextType['setSubmitSuccessful'] = + useCallback((form, value) => { + submitSuccessfulList.current[form.name] = value; + }, []); + + const submit: GlobalFormsContextType['submit'] = useCallback( + async (additionalCallback?: CallbackWithOrder) => { + if (submitCallbacks) { + Object.keys(submitCallbacks).forEach((formName) => { + submitSuccessfulList.current[formName] = false; + }); + } + const callbacks = Object.values(submitCallbacks ?? {}); + if (additionalCallback) { + callbacks.push(additionalCallback); + } + const sortedCallbacks = sortBy(callbacks, (wrapper) => wrapper.order).map( + (wrapper) => wrapper.callback, + ); + await callSequentially(sortedCallbacks); + + return Object.values(submitSuccessfulList.current).every(Boolean); + }, + [submitCallbacks], + ); + + const setSubmitting: GlobalFormsContextType['setSubmitting'] = useCallback( + (form, value) => { + setSubmittings((prevSubmittings) => ({ + ...prevSubmittings, + [form.name]: value, + })); + }, + [], + ); + + const isSubmitting = useMemo( + () => Object.values(submittings ?? {}).some(Boolean), + [submittings], + ); + + const reset: GlobalFormsContextType['reset'] = useCallback( + () => Object.values(resetCallbacks ?? {}).forEach((callback) => callback()), + [resetCallbacks], + ); + + const value = useMemo( + () => ({ + isSubmitting, + setSubmitSuccessful, + reset, + setResetCallback, + setSubmitCallback, + setSubmitting, + submit, + }), + [ + isSubmitting, + setSubmitSuccessful, + reset, + setResetCallback, + setSubmitCallback, + setSubmitting, + submit, + ], + ); + + return ( + + {children} + + ); +}