From 6c4f051cfebf1bf021c7b3df2e30e63ef9c5cdbe Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Wed, 17 Jul 2024 11:56:31 +0800 Subject: [PATCH] refactor(experience): support and apply modal loading state (#6236) * refactor(experience): support and apply modal loading state * feat(experience): support cancel loading for modal --- .../Providers/ConfirmModalProvider/index.tsx | 119 +++++++--- .../ConfirmModalProvider/indext.test.tsx | 214 +++++++++++++----- .../src/components/ConfirmModal/AcModal.tsx | 4 + .../components/ConfirmModal/MobileModal.tsx | 10 +- .../src/components/ConfirmModal/type.ts | 2 + .../use-identifier-error-alert.ts | 4 +- .../use-link-social-confirm-modal.ts | 16 +- .../use-register-flow-code-verification.ts | 36 ++- .../use-sign-in-flow-code-verification.ts | 40 ++-- .../experience/src/hooks/use-confirm-modal.ts | 47 +++- packages/experience/src/hooks/use-terms.ts | 4 +- .../src/pages/Continue/SetPassword/index.tsx | 4 +- .../src/pages/RegisterPassword/index.tsx | 4 +- .../src/pages/ResetPassword/index.tsx | 4 +- 14 files changed, 350 insertions(+), 158 deletions(-) diff --git a/packages/experience/src/Providers/ConfirmModalProvider/index.tsx b/packages/experience/src/Providers/ConfirmModalProvider/index.tsx index 1a2bd1377..5836a5c87 100644 --- a/packages/experience/src/Providers/ConfirmModalProvider/index.tsx +++ b/packages/experience/src/Providers/ConfirmModalProvider/index.tsx @@ -6,30 +6,38 @@ import type { ModalProps } from '@/components/ConfirmModal'; import { WebModal, MobileModal } from '@/components/ConfirmModal'; import usePlatform from '@/hooks/use-platform'; -export type ModalContentRenderProps = { - confirm: (data?: unknown) => void; - cancel: (data?: unknown) => void; -}; - type ConfirmModalType = 'alert' | 'confirm'; type ConfirmModalState = Omit & { - ModalContent: string | ((props: ModalContentRenderProps) => Nullable); + ModalContent: string | (() => Nullable); type: ConfirmModalType; + isConfirmLoading?: boolean; + isCancelLoading?: boolean; }; -type ConfirmModalProps = Omit & { type?: ConfirmModalType }; +/** + * Props for promise-based modal usage + */ +type PromiseConfirmModalProps = Omit & { + type?: ConfirmModalType; +}; + +/** + * Props for callback-based modal usage + */ +export type CallbackConfirmModalProps = PromiseConfirmModalProps & { + onConfirm?: () => Promise | void; + onCancel?: () => Promise | void; +}; type ConfirmModalContextType = { - show: (props: ConfirmModalProps) => Promise<[boolean, unknown?]>; - confirm: (data?: unknown) => void; - cancel: (data?: unknown) => void; + showPromise: (props: PromiseConfirmModalProps) => Promise<[boolean, unknown?]>; + showCallback: (props: CallbackConfirmModalProps) => void; }; export const ConfirmModalContext = createContext({ - show: async () => [true], - confirm: noop, - cancel: noop, + showPromise: async () => [true], + showCallback: noop, }); type Props = { @@ -40,49 +48,90 @@ const defaultModalState: ConfirmModalState = { isOpen: false, type: 'confirm', ModalContent: () => null, + isConfirmLoading: false, + isCancelLoading: false, }; +/** + * ConfirmModalProvider component + * + * This component provides a context for managing confirm modals throughout the application. + * It supports both promise-based and callback-based usage patterns. see `usePromiseConfirmModal` and `useConfirmModal` hooks. + */ const ConfirmModalProvider = ({ children }: Props) => { const [modalState, setModalState] = useState(defaultModalState); const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>(); + const callbackRef = useRef<{ + onConfirm?: () => Promise | void; + onCancel?: () => Promise | void; + }>({}); const { isMobile } = usePlatform(); const ConfirmModal = isMobile ? MobileModal : WebModal; - const handleShow = useCallback(async ({ type = 'confirm', ...props }: ConfirmModalProps) => { - resolver.current?.([false]); + const handleShowPromise = useCallback( + async ({ type = 'confirm', ...props }: PromiseConfirmModalProps) => { + resolver.current?.([false]); - setModalState({ - isOpen: true, - type, - ...props, - }); + setModalState({ + isOpen: true, + type, + isConfirmLoading: false, + isCancelLoading: false, + ...props, + }); + + return new Promise<[result: boolean, data?: unknown]>((resolve) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + resolver.current = resolve; + }); + }, + [] + ); + + const handleShowCallback = useCallback( + ({ type = 'confirm', onConfirm, onCancel, ...props }: CallbackConfirmModalProps) => { + resolver.current?.([false]); + + setModalState({ + isOpen: true, + type, + isConfirmLoading: false, + ...props, + }); - return new Promise<[result: boolean, data?: unknown]>((resolve) => { // eslint-disable-next-line @silverhand/fp/no-mutation - resolver.current = resolve; - }); - }, []); + callbackRef.current = { onConfirm, onCancel }; + }, + [] + ); - const handleConfirm = useCallback((data?: unknown) => { + const handleConfirm = useCallback(async (data?: unknown) => { + if (callbackRef.current.onConfirm) { + setModalState((previous) => ({ ...previous, isConfirmLoading: true })); + await callbackRef.current.onConfirm(); + } resolver.current?.([true, data]); setModalState(defaultModalState); }, []); - const handleCancel = useCallback((data?: unknown) => { + const handleCancel = useCallback(async (data?: unknown) => { + if (callbackRef.current.onCancel) { + setModalState((previous) => ({ ...previous, isCancelLoading: true })); + await callbackRef.current.onCancel(); + } resolver.current?.([false, data]); setModalState(defaultModalState); }, []); const contextValue = useMemo( () => ({ - show: handleShow, - confirm: handleConfirm, - cancel: handleCancel, + showPromise: handleShowPromise, + showCallback: handleShowCallback, }), - [handleCancel, handleConfirm, handleShow] + [handleShowPromise, handleShowCallback] ); const { ModalContent, type, ...restProps } = modalState; @@ -95,19 +144,15 @@ const ConfirmModalProvider = ({ children }: Props) => { onConfirm={ type === 'confirm' ? () => { - handleConfirm(); + void handleConfirm(); } : undefined } onClose={() => { - handleCancel(); + void handleCancel(); }} > - {typeof ModalContent === 'string' ? ( - ModalContent - ) : ( - - )} + {typeof ModalContent === 'string' ? ModalContent : } ); diff --git a/packages/experience/src/Providers/ConfirmModalProvider/indext.test.tsx b/packages/experience/src/Providers/ConfirmModalProvider/indext.test.tsx index 16072159e..acb80bd80 100644 --- a/packages/experience/src/Providers/ConfirmModalProvider/indext.test.tsx +++ b/packages/experience/src/Providers/ConfirmModalProvider/indext.test.tsx @@ -1,15 +1,15 @@ import { render, fireEvent, waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; -import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import { useConfirmModal, usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; import ConfirmModalProvider from '.'; const confirmHandler = jest.fn(); const cancelHandler = jest.fn(); -const ConfirmModalTestComponent = () => { - const { show } = useConfirmModal(); +const PromiseConfirmModalTestComponent = () => { + const { show } = usePromiseConfirmModal(); const onClick = async () => { const [result] = await show({ ModalContent: 'confirm modal content' }); @@ -26,82 +26,178 @@ const ConfirmModalTestComponent = () => { return ; }; +const CallbackConfirmModalTestComponent = () => { + const { show } = useConfirmModal(); + + const onClick = () => { + show({ + ModalContent: 'confirm modal content', + onConfirm: confirmHandler, + onCancel: cancelHandler, + }); + }; + + return ; +}; + describe('confirm modal provider', () => { - it('render confirm modal', async () => { - const { queryByText, getByText } = render( - - - - ); + describe('promise confirm modal', () => { + it('render confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); - const trigger = getByText('show modal'); + const trigger = getByText('show modal'); - act(() => { - fireEvent.click(trigger); + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); }); - await waitFor(() => { - expect(queryByText('confirm modal content')).not.toBeNull(); - expect(queryByText('action.confirm')).not.toBeNull(); - expect(queryByText('action.cancel')).not.toBeNull(); + it('confirm callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + }); + + const confirm = getByText('action.confirm'); + + act(() => { + fireEvent.click(confirm); + }); + + await waitFor(() => { + expect(confirmHandler).toBeCalled(); + }); + }); + + it('cancel callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + + const cancel = getByText('action.cancel'); + + act(() => { + fireEvent.click(cancel); + }); + + await waitFor(() => { + expect(cancelHandler).toBeCalled(); + }); }); }); - it('confirm callback of confirm modal', async () => { - const { queryByText, getByText } = render( - - - - ); + describe('callback confirm modal', () => { + it('render confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); - const trigger = getByText('show modal'); + const trigger = getByText('show modal'); - act(() => { - fireEvent.click(trigger); + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); }); - await waitFor(() => { - expect(queryByText('confirm modal content')).not.toBeNull(); - expect(queryByText('action.confirm')).not.toBeNull(); + it('confirm callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + }); + + const confirm = getByText('action.confirm'); + + act(() => { + fireEvent.click(confirm); + }); + + await waitFor(() => { + expect(confirmHandler).toBeCalled(); + }); }); - const confirm = getByText('action.confirm'); + it('cancel callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); - act(() => { - fireEvent.click(confirm); - }); + const trigger = getByText('show modal'); - await waitFor(() => { - expect(confirmHandler).toBeCalled(); - }); - }); + act(() => { + fireEvent.click(trigger); + }); - it('cancel callback of confirm modal', async () => { - const { queryByText, getByText } = render( - - - - ); + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); - const trigger = getByText('show modal'); + const cancel = getByText('action.cancel'); - act(() => { - fireEvent.click(trigger); - }); + act(() => { + fireEvent.click(cancel); + }); - await waitFor(() => { - expect(queryByText('confirm modal content')).not.toBeNull(); - expect(queryByText('action.cancel')).not.toBeNull(); - }); - - const cancel = getByText('action.cancel'); - - act(() => { - fireEvent.click(cancel); - }); - - await waitFor(() => { - expect(cancelHandler).toBeCalled(); + await waitFor(() => { + expect(cancelHandler).toBeCalled(); + }); }); }); }); diff --git a/packages/experience/src/components/ConfirmModal/AcModal.tsx b/packages/experience/src/components/ConfirmModal/AcModal.tsx index 0f7ce4597..17c454783 100644 --- a/packages/experience/src/components/ConfirmModal/AcModal.tsx +++ b/packages/experience/src/components/ConfirmModal/AcModal.tsx @@ -16,6 +16,8 @@ import type { ModalProps } from './type'; const AcModal = ({ className, isOpen = false, + isConfirmLoading = false, + isCancelLoading = false, children, cancelText = 'action.cancel', confirmText = 'action.confirm', @@ -62,6 +64,7 @@ const AcModal = ({ type="secondary" i18nProps={cancelTextI18nProps} size="small" + isLoading={isCancelLoading} onClick={onClose} /> {onConfirm && ( @@ -69,6 +72,7 @@ const AcModal = ({ title={confirmText} i18nProps={confirmTextI18nProps} size="small" + isLoading={isConfirmLoading} onClick={onConfirm} /> )} diff --git a/packages/experience/src/components/ConfirmModal/MobileModal.tsx b/packages/experience/src/components/ConfirmModal/MobileModal.tsx index 9b398da3c..0e888fa5d 100644 --- a/packages/experience/src/components/ConfirmModal/MobileModal.tsx +++ b/packages/experience/src/components/ConfirmModal/MobileModal.tsx @@ -11,6 +11,8 @@ import type { ModalProps } from './type'; const MobileModal = ({ className, isOpen = false, + isConfirmLoading = false, + isCancelLoading = false, children, cancelText = 'action.cancel', confirmText = 'action.confirm', @@ -34,11 +36,17 @@ const MobileModal = ({