From e3238b38c5d9617e5f39352796d5cbdd6c7ec896 Mon Sep 17 00:00:00 2001 From: Quernest Date: Sun, 3 Dec 2023 14:20:47 +0200 Subject: [PATCH] #86 added global configuration object and enforceProvider rule --- src/index.ts | 1 + src/modal-config.ts | 17 +++++++ src/modal-context.test.tsx | 84 ++++++++++++++++++++++++---------- src/modal-context.ts | 14 ++++++ src/modal-provider.test.tsx | 27 ++++++----- src/types.ts | 4 ++ src/use-modal-context.test.tsx | 3 ++ src/use-modal-context.ts | 8 ++-- src/use-modal.ts | 13 +++--- 9 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 src/modal-config.ts diff --git a/src/index.ts b/src/index.ts index d0e37f4..b6297e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export { } from './modal-provider'; export { default as useModal, UseModalOptions } from './use-modal'; export * from './types'; +export * from './modal-config'; diff --git a/src/modal-config.ts b/src/modal-config.ts new file mode 100644 index 0000000..7e54e9e --- /dev/null +++ b/src/modal-config.ts @@ -0,0 +1,17 @@ +import { ModalConfig } from './types'; + +const config: ModalConfig = { + /** + * If set to `true` you will get an error when trying to access + * a context without the `ModalProvider` declared above. + */ + enforceProvider: false, +}; + +export function setModalConfig(newConfig: Partial) { + Object.assign(config, newConfig); +} + +export function getModalConfig() { + return config; +} diff --git a/src/modal-context.test.tsx b/src/modal-context.test.tsx index 207d241..e0fcdda 100644 --- a/src/modal-context.test.tsx +++ b/src/modal-context.test.tsx @@ -1,47 +1,85 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { ModalProviderWrapper as wrapper } from './test-utils'; -import useModalContext from './use-modal-context'; +import { + act, + renderHook, + WrapperComponent, +} from '@testing-library/react-hooks'; +import { ModalProviderWrapper } from './test-utils'; +import useModal from './use-modal'; +import { ModalContextState } from './modal-context'; describe('ModalContext', () => { const rootId = '123'; const modalId = '321'; - test('should be initialized with correct state', () => { - const { result } = renderHook(() => useModalContext(), { - wrapper, - }); + const testHook = (wrapper?: WrapperComponent) => { + const { result } = renderHook(() => useModal(), { wrapper }); + const ctx = result.current as ModalContextState; - expect(result.current).toMatchObject({ - destroyModal: expect.any(Function), - destroyModalsByRootId: expect.any(Function), - hideModal: expect.any(Function), - showModal: expect.any(Function), - state: {}, - updateModal: expect.any(Function), - }); + const performModalActions = (modal: any) => { + act(() => { + modal.update({}); + modal.hide(); + modal.destroy(); + }); + }; + + return { ctx, performModalActions }; + }; + + const runTests = ( + context: ModalContextState, + wrapper?: WrapperComponent + ) => { + const { performModalActions } = testHook(wrapper); act(() => { - const modal = result.current.showModal(() =>
test
); - modal.update({}); - modal.hide(); - modal.destroy(); + const modal = context.showModal(() =>
test
); + performModalActions(modal); }); act(() => { - result.current.updateModal(modalId, {}); + context.updateModal(modalId, {}); }); act(() => { - result.current.hideModal(modalId); + context.hideModal(modalId); }); act(() => { - result.current.destroyModal(modalId); + context.destroyModal(modalId); }); act(() => { - result.current.destroyModalsByRootId(rootId); + context.destroyModalsByRootId(rootId); }); + }; + + test('should be initialized with correct state', () => { + const { ctx } = testHook(ModalProviderWrapper); + expect(ctx).toMatchObject({ + destroyModal: expect.any(Function), + destroyModalsByRootId: expect.any(Function), + hideModal: expect.any(Function), + showModal: expect.any(Function), + state: {}, + updateModal: expect.any(Function), + }); + + runTests(ctx, ModalProviderWrapper); + }); + + test('should be initialized without context provider and return fallback', () => { + const { ctx } = testHook(undefined); + expect(ctx).toMatchObject({ + destroyModal: expect.any(Function), + destroyModalsByRootId: expect.any(Function), + hideModal: expect.any(Function), + showModal: expect.any(Function), + state: {}, + updateModal: expect.any(Function), + }); + + runTests(ctx); }); }); diff --git a/src/modal-context.ts b/src/modal-context.ts index 84cf280..bbfa58a 100644 --- a/src/modal-context.ts +++ b/src/modal-context.ts @@ -17,6 +17,20 @@ export interface ModalContextState { showModal: ShowFn; } +export const modalContextFallback: ModalContextState = { + state: {}, + updateModal: () => undefined, + hideModal: () => undefined, + destroyModal: () => undefined, + destroyModalsByRootId: () => undefined, + showModal: () => ({ + id: 'id', + hide: () => undefined, + destroy: () => undefined, + update: () => undefined, + }), +}; + const ModalContext = createContext(undefined); export default ModalContext; diff --git a/src/modal-provider.test.tsx b/src/modal-provider.test.tsx index c81f9b0..e0e5806 100644 --- a/src/modal-provider.test.tsx +++ b/src/modal-provider.test.tsx @@ -1,6 +1,5 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as utils from './utils'; -import useModalContext from './use-modal-context'; import { LegacyModalProviderWrapper as legacyWrapper, ModalProviderWrapper as wrapper, @@ -12,6 +11,7 @@ import Modal, { ModalProps } from './test-utils/modal'; import LegacyModal from './test-utils/legacy-modal'; import { Options, ShowFnOutput, State } from './types'; import { MISSED_MODAL_ID_ERROR_MESSAGE } from './constants'; +import useModal from './use-modal'; describe('ModalProvider', () => { const rootId = '000'; @@ -33,7 +33,7 @@ describe('ModalProvider', () => { let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { - uidSpy = jest.spyOn(utils, 'uid').mockReturnValueOnce(modalId); + uidSpy = jest.spyOn(utils, 'uid').mockReturnValue(modalId); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -43,7 +43,8 @@ describe('ModalProvider', () => { }); test('happy path scenario (with options)', () => { - const { result } = renderHook(() => useModalContext(), { + uidSpy = jest.spyOn(utils, 'uid').mockReturnValueOnce(rootId); + const { result } = renderHook(() => useModal(), { wrapper, }); @@ -86,7 +87,7 @@ describe('ModalProvider', () => { }); test('unhappy path (missed ID errors)', () => { - const { result } = renderHook(() => useModalContext(), { + const { result } = renderHook(() => useModal(), { wrapper, }); @@ -123,8 +124,9 @@ describe('ModalProvider', () => { }); }); - test('happy path scenario (without options)', () => { - const { result } = renderHook(() => useModalContext(), { + test('happy path scenario (without options provided)', () => { + uidSpy = jest.spyOn(utils, 'uid').mockReturnValueOnce(rootId); + const { result } = renderHook(() => useModal(), { wrapper, }); @@ -133,8 +135,11 @@ describe('ModalProvider', () => { }); const expectedState: State = { - [modalId]: { + [id]: { component: Modal, + options: { + rootId: rootId, + }, props: { open: true, ...modalProps, @@ -146,7 +151,7 @@ describe('ModalProvider', () => { }); it('should automaticaly destroy on close', () => { - const { result } = renderHook(() => useModalContext(), { + const { result } = renderHook(() => useModal(), { wrapper, }); @@ -163,7 +168,7 @@ describe('ModalProvider', () => { }); it('should fire onClose prop event on hide', () => { - const { result } = renderHook(() => useModalContext(), { + const { result } = renderHook(() => useModal(), { wrapper, }); @@ -184,7 +189,7 @@ describe('ModalProvider', () => { }); it('should fire TransitionProps.onExited prop event on hide', () => { - const { result } = renderHook(() => useModalContext(), { + const { result } = renderHook(() => useModal(), { wrapper: noSuspenseWrapper, }); @@ -205,7 +210,7 @@ describe('ModalProvider', () => { }); it('should fire onExited prop event on hide', () => { - const { result } = renderHook(() => useModalContext(), { + const { result } = renderHook(() => useModal(), { wrapper: legacyWrapper, }); diff --git a/src/types.ts b/src/types.ts index bdb4fc9..e509edb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,3 +56,7 @@ export interface ShowFnOutput

{ destroy: () => void; update: (newProps: Partial>) => void; } + +export interface ModalConfig { + enforceProvider: boolean; +} diff --git a/src/use-modal-context.test.tsx b/src/use-modal-context.test.tsx index 91c022a..be53f31 100644 --- a/src/use-modal-context.test.tsx +++ b/src/use-modal-context.test.tsx @@ -1,3 +1,4 @@ +import { setModalConfig } from './modal-config'; import { useContext } from 'react'; import useModalContext from './use-modal-context'; @@ -9,9 +10,11 @@ jest.mock('react', () => ({ describe('useModalContext', () => { it('throws an error when not used within ModalProvider', () => { (useContext as jest.Mock).mockReturnValue(undefined); + setModalConfig({ enforceProvider: true }); expect(() => useModalContext()).toThrow( 'useModalContext must be used within a ModalProvider' ); + setModalConfig({ enforceProvider: false }); }); it('returns the context when used within ModalProvider', () => { diff --git a/src/use-modal-context.ts b/src/use-modal-context.ts index c54b63a..6ad6102 100644 --- a/src/use-modal-context.ts +++ b/src/use-modal-context.ts @@ -1,12 +1,14 @@ import { useContext } from 'react'; -import ModalContext from './modal-context'; +import ModalContext, { modalContextFallback } from './modal-context'; +import { getModalConfig } from './modal-config'; export default function useModalContext() { const context = useContext(ModalContext); + const { enforceProvider } = getModalConfig(); - if (context === undefined) { + if (enforceProvider && context === undefined) { throw new Error('useModalContext must be used within a ModalProvider'); } - return context; + return context || modalContextFallback; } diff --git a/src/use-modal.ts b/src/use-modal.ts index de8e080..dbe0cdc 100644 --- a/src/use-modal.ts +++ b/src/use-modal.ts @@ -15,18 +15,18 @@ export default function useModal(options: UseModalOptions = defaultOptions) { const { disableAutoDestroy } = { ...defaultOptions, ...options }; const { showModal, - destroyModalsByRootId: destroy, - ...otherContextProps + destroyModal, + ...otherModalContextProps } = useModalContext(); const id = useRef(uid(6)); useEffect( () => () => { - if (!disableAutoDestroy) { - destroy(id.current); + if (!disableAutoDestroy && destroyModal) { + destroyModal(id.current); } }, - [disableAutoDestroy, destroy] + [disableAutoDestroy, destroyModal] ); return { @@ -35,6 +35,7 @@ export default function useModal(options: UseModalOptions = defaultOptions) { showModal(component, props, { rootId: id.current, ...options }), [showModal] ), - ...otherContextProps, + destroyModal, + ...otherModalContextProps, }; }