0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(experience): support and apply modal loading state (#6236)

* refactor(experience): support and apply modal loading state

* feat(experience): support cancel loading for modal
This commit is contained in:
Xiao Yijun 2024-07-17 11:56:31 +08:00 committed by GitHub
parent bc2ccf671e
commit 6c4f051cfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 350 additions and 158 deletions

View file

@ -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<ModalProps, 'onClose' | 'onConfirm' | 'children'> & {
ModalContent: string | ((props: ModalContentRenderProps) => Nullable<JSX.Element>);
ModalContent: string | (() => Nullable<JSX.Element>);
type: ConfirmModalType;
isConfirmLoading?: boolean;
isCancelLoading?: boolean;
};
type ConfirmModalProps = Omit<ConfirmModalState, 'isOpen' | 'type'> & { type?: ConfirmModalType };
/**
* Props for promise-based modal usage
*/
type PromiseConfirmModalProps = Omit<ConfirmModalState, 'isOpen' | 'type' | 'isConfirmLoading'> & {
type?: ConfirmModalType;
};
/**
* Props for callback-based modal usage
*/
export type CallbackConfirmModalProps = PromiseConfirmModalProps & {
onConfirm?: () => Promise<void> | void;
onCancel?: () => Promise<void> | 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<ConfirmModalContextType>({
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<ConfirmModalState>(defaultModalState);
const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>();
const callbackRef = useRef<{
onConfirm?: () => Promise<void> | void;
onCancel?: () => Promise<void> | 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
) : (
<ModalContent confirm={handleConfirm} cancel={handleCancel} />
)}
{typeof ModalContent === 'string' ? ModalContent : <ModalContent />}
</ConfirmModal>
</ConfirmModalContext.Provider>
);

View file

@ -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 <button onClick={onClick}>show modal</button>;
};
const CallbackConfirmModalTestComponent = () => {
const { show } = useConfirmModal();
const onClick = () => {
show({
ModalContent: 'confirm modal content',
onConfirm: confirmHandler,
onCancel: cancelHandler,
});
};
return <button onClick={onClick}>show modal</button>;
};
describe('confirm modal provider', () => {
it('render confirm modal', async () => {
const { queryByText, getByText } = render(
<ConfirmModalProvider>
<ConfirmModalTestComponent />
</ConfirmModalProvider>
);
describe('promise confirm modal', () => {
it('render confirm modal', async () => {
const { queryByText, getByText } = render(
<ConfirmModalProvider>
<PromiseConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<PromiseConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<PromiseConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<ConfirmModalTestComponent />
</ConfirmModalProvider>
);
describe('callback confirm modal', () => {
it('render confirm modal', async () => {
const { queryByText, getByText } = render(
<ConfirmModalProvider>
<CallbackConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<CallbackConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<CallbackConfirmModalTestComponent />
</ConfirmModalProvider>
);
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(
<ConfirmModalProvider>
<ConfirmModalTestComponent />
</ConfirmModalProvider>
);
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();
});
});
});
});

View file

@ -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}
/>
)}

View file

@ -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 = ({
<Button
title={cancelText}
i18nProps={cancelTextI18nProps}
isLoading={isCancelLoading}
type="secondary"
onClick={onClose}
/>
{onConfirm && (
<Button title={confirmText} i18nProps={confirmTextI18nProps} onClick={onConfirm} />
<Button
title={confirmText}
i18nProps={confirmTextI18nProps}
isLoading={isConfirmLoading}
onClick={onConfirm}
/>
)}
</div>
</div>

View file

@ -4,6 +4,8 @@ import type { ReactNode } from 'react';
export type ModalProps = {
className?: string;
isOpen?: boolean;
isConfirmLoading?: boolean;
isCancelLoading?: boolean;
children: ReactNode;
cancelText?: TFuncKey;
confirmText?: TFuncKey;

View file

@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import type { VerificationCodeIdentifier } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
@ -13,7 +13,7 @@ export enum IdentifierErrorType {
}
const useIdentifierErrorAlert = () => {
const { show } = useConfirmModal();
const { show } = usePromiseConfirmModal();
const navigate = useNavigate();
const { t } = useTranslation();

View file

@ -16,7 +16,7 @@ const useLinkSocialConfirmModal = () => {
return useCallback(
async (method: VerificationCodeIdentifier, target: string, connectorId: string) => {
const [confirm] = await show({
show({
confirmText: 'action.bind_and_continue',
cancelText: 'action.change',
cancelTextI18nProps: {
@ -29,15 +29,13 @@ const useLinkSocialConfirmModal = () => {
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
onConfirm: async () => {
await linkWithSocial(connectorId);
},
onCancel: () => {
navigate(-1);
},
});
if (!confirm) {
navigate(-1);
return;
}
await linkWithSocial(connectorId);
},
[linkWithSocial, navigate, show, t]
);

View file

@ -50,7 +50,7 @@ const useRegisterFlowCodeVerification = (
return;
}
const [confirm] = await show({
show({
confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', {
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
@ -59,25 +59,23 @@ const useRegisterFlowCodeVerification = (
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
onConfirm: async () => {
const [error, result] = await signInWithIdentifierAsync();
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result?.redirectTo) {
redirectTo(result.redirectTo);
}
},
onCancel: () => {
navigate(-1);
},
});
if (!confirm) {
navigate(-1);
return;
}
const [error, result] = await signInWithIdentifierAsync();
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result?.redirectTo) {
redirectTo(result.redirectTo);
}
}, [
handleError,
method,

View file

@ -51,7 +51,7 @@ const useSignInFlowCodeVerification = (
return;
}
const [confirm] = await show({
show({
confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exist', {
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
@ -60,27 +60,25 @@ const useSignInFlowCodeVerification = (
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
onConfirm: async () => {
const [error, result] = await registerWithIdentifierAsync(
method === SignInIdentifier.Email ? { email: target } : { phone: target }
);
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result?.redirectTo) {
redirectTo(result.redirectTo);
}
},
onCancel: () => {
navigate(-1);
},
});
if (!confirm) {
navigate(-1);
return;
}
const [error, result] = await registerWithIdentifierAsync(
method === SignInIdentifier.Email ? { email: target } : { phone: target }
);
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result?.redirectTo) {
redirectTo(result.redirectTo);
}
}, [
signInMode,
show,

View file

@ -2,6 +2,49 @@ import { useContext } from 'react';
import { ConfirmModalContext } from '@/Providers/ConfirmModalProvider';
export type { ModalContentRenderProps } from '@/Providers/ConfirmModalProvider';
/**
* Hook for using the promise-based confirm modal
*
* @returns An object with a `show` method that returns a promise
*
* Example:
* ```ts
* const { show } = usePromiseConfirmModal();
* const [result] = await show({ ModalContent: 'Are you sure?' });
* if (result) {
* // User confirmed
* }
*```
*/
export const usePromiseConfirmModal = () => {
const { showPromise } = useContext(ConfirmModalContext);
export const useConfirmModal = () => useContext(ConfirmModalContext);
return { show: showPromise };
};
/**
* Hook for using the callback-based confirm modal
*
* @returns An object with a `show` method that accepts callbacks
*
* Example:
* ```ts
* const { show } = useConfirmModal();
* show({
* ModalContent: 'Are you sure?',
* onConfirm: async () => {
* // This will automatically set the confirm button to loading state
* await someAsyncOperation();
* },
* onCancel: async () => {
* // This will automatically set the cancel button to loading state
* await someAsyncOperation();
* }
* });
* ```
*/
export const useConfirmModal = () => {
const { showCallback } = useContext(ConfirmModalContext);
return { show: showCallback };
};

View file

@ -5,11 +5,11 @@ import { useCallback, useContext, useMemo } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import TermsAndPrivacyConfirmModalContent from '@/containers/TermsAndPrivacyConfirmModalContent';
import { useConfirmModal } from './use-confirm-modal';
import { usePromiseConfirmModal } from './use-confirm-modal';
const useTerms = () => {
const { termsAgreement, setTermsAgreement, experienceSettings } = useContext(PageContext);
const { show } = useConfirmModal();
const { show } = usePromiseConfirmModal();
const { termsOfUseUrl, privacyPolicyUrl, isTermsDisabled, agreeToTermsPolicy } = useMemo(() => {
const { termsOfUseUrl, privacyPolicyUrl, agreeToTermsPolicy } = experienceSettings ?? {};

View file

@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import { addProfile } from '@/apis/interaction';
import SetPasswordForm from '@/containers/SetPassword';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
@ -18,7 +18,7 @@ const SetPassword = () => {
}, []);
const navigate = useNavigate();
const { show } = useConfirmModal();
const { show } = usePromiseConfirmModal();
const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler();

View file

@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import { setUserPassword } from '@/apis/interaction';
import SetPassword from '@/containers/SetPassword';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import { type ErrorHandlers } from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useMfaErrorHandler from '@/hooks/use-mfa-error-handler';
@ -19,7 +19,7 @@ const RegisterPassword = () => {
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();
const { show } = useConfirmModal();
const { show } = usePromiseConfirmModal();
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
setErrorMessage(undefined);

View file

@ -6,7 +6,7 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { setUserPassword } from '@/apis/interaction';
import SetPassword from '@/containers/SetPassword';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import { type ErrorHandlers } from '@/hooks/use-error-handler';
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
import { usePasswordPolicy } from '@/hooks/use-sie';
@ -20,7 +20,7 @@ const ResetPassword = () => {
const { t } = useTranslation();
const { setToast } = useToast();
const navigate = useNavigate();
const { show } = useConfirmModal();
const { show } = usePromiseConfirmModal();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const errorHandlers: ErrorHandlers = useMemo(
() => ({