0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(ui): global confirm modal (#2018)

* feat(ui): global confirm modal

gloabl configm modal

* fix(ui): cr update

cr update
This commit is contained in:
simeng-li 2022-09-30 10:31:30 +08:00 committed by GitHub
parent bd0596f035
commit f1ca49c892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 271 additions and 20 deletions

View file

@ -38,7 +38,7 @@ const AcModal = ({
<div className={styles.content}>{children}</div>
<div className={styles.footer}>
<Button title={cancelText} type="outline" size="small" onClick={onClose} />
<Button title={confirmText} size="small" onClick={onConfirm ?? onClose} />
{onConfirm && <Button title={confirmText} size="small" onClick={onConfirm} />}
</div>
</div>
</ReactModal>

View file

@ -1,6 +1,5 @@
import classNames from 'classnames';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
@ -21,7 +20,6 @@ const IframeConfirmModal = ({
onConfirm,
onClose,
}: Props) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(true);
return (

View file

@ -1,5 +1,4 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
@ -17,8 +16,6 @@ const MobileModal = ({
onConfirm,
onClose,
}: ModalProps) => {
const { t } = useTranslation();
return (
<ReactModal
role="dialog"
@ -30,7 +27,7 @@ const MobileModal = ({
<div className={styles.content}>{children}</div>
<div className={styles.footer}>
<Button title={cancelText} type="secondary" onClick={onClose} />
<Button title={confirmText} onClick={onConfirm ?? onClose} />
{onConfirm && <Button title={confirmText} onClick={onConfirm} />}
</div>
</div>
</ReactModal>

View file

@ -2,3 +2,5 @@ export { default as WebModal } from './AcModal';
export { default as MobileModal } from './MobileModal';
export { default as IframeModal } from './IframeConfirmModal';
export { modalPromisify } from './modalPromisify';
export type { ModalProps } from './type';

View file

@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { ReactNode, MouseEventHandler } from 'react';
import { TFuncKey } from 'react-i18next';
export type ModalProps = {
@ -7,6 +7,6 @@ export type ModalProps = {
children: ReactNode;
cancelText?: TFuncKey;
confirmText?: TFuncKey;
onConfirm?: () => void;
onClose: () => void;
onConfirm?: MouseEventHandler<HTMLButtonElement> & MouseEventHandler;
onClose: MouseEventHandler<HTMLButtonElement> & MouseEventHandler;
};

View file

@ -2,6 +2,7 @@ import { conditionalString } from '@silverhand/essentials';
import { ReactNode, useEffect, useCallback, useContext } from 'react';
import Toast from '@/components/Toast';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import useColorTheme from '@/hooks/use-color-theme';
import { PageContext } from '@/hooks/use-page-context';
import useTheme from '@/hooks/use-theme';
@ -37,12 +38,14 @@ const AppContent = ({ children }: Props) => {
}, [platform]);
return (
<ConfirmModalProvider>
<div className={styles.container}>
{platform === 'web' && <div className={styles.placeHolder} />}
<main className={styles.content}>{children}</main>
{platform === 'web' && <div className={styles.placeHolder} />}
<Toast message={toast} callback={hideToast} />
</div>
</ConfirmModalProvider>
);
};

View file

@ -0,0 +1,118 @@
import { Nullable } from '@silverhand/essentials';
import { useState, useRef, useMemo, createContext, useCallback } from 'react';
import { WebModal, MobileModal, ModalProps } from '@/components/ConfirmModal';
import usePlatform from '@/hooks/use-platform';
export type ChildRenderProps = {
confirm: (data?: unknown) => void;
cancel: (data?: unknown) => void;
};
type ConfirmModalType = 'alert' | 'confirm';
type ConfirmModalState = Omit<ModalProps, 'onClose' | 'onConfirm' | 'children'> & {
ModalContent: string | ((props: ChildRenderProps) => Nullable<JSX.Element>);
type: ConfirmModalType;
};
type ConfirmModalProps = Omit<ConfirmModalState, 'isOpen' | 'type'> & { type?: ConfirmModalType };
type ConfirmModalContextType = {
show: (props: ConfirmModalProps) => Promise<[boolean, unknown?]>;
confirm: (data?: unknown) => void;
cancel: (data?: unknown) => void;
};
const noop = () => {
throw new Error('Context provider not found');
};
export const ConfirmModalContext = createContext<ConfirmModalContextType>({
show: async () => [true],
confirm: noop,
cancel: noop,
});
type Props = {
children?: React.ReactNode;
};
const defaultModalState: ConfirmModalState = {
isOpen: false,
type: 'confirm',
ModalContent: () => null,
};
const ConfirmModalProvider = ({ children }: Props) => {
const [modalState, setModalState] = useState<ConfirmModalState>(defaultModalState);
const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>();
const { isMobile } = usePlatform();
const ConfirmModal = isMobile ? MobileModal : WebModal;
const handleShow = useCallback(async ({ type = 'confirm', ...props }: ConfirmModalProps) => {
resolver.current?.([false]);
setModalState({
isOpen: true,
type,
...props,
});
return new Promise<[result: boolean, data?: unknown]>((resolve) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
resolver.current = resolve;
});
}, []);
const handleConfirm = useCallback((data?: unknown) => {
resolver.current?.([true, data]);
setModalState(defaultModalState);
}, []);
const handleCancel = useCallback((data?: unknown) => {
resolver.current?.([false, data]);
setModalState(defaultModalState);
}, []);
const contextValue = useMemo(
() => ({
show: handleShow,
confirm: handleConfirm,
cancel: handleCancel,
}),
[handleCancel, handleConfirm, handleShow]
);
const { ModalContent, type, ...restProps } = modalState;
return (
<ConfirmModalContext.Provider value={contextValue}>
{children}
<ConfirmModal
{...restProps}
onConfirm={
type === 'confirm'
? () => {
handleConfirm();
}
: undefined
}
onClose={() => {
handleCancel();
}}
>
{typeof ModalContent === 'string' ? (
ModalContent
) : (
<ModalContent confirm={handleConfirm} cancel={handleCancel} />
)}
</ConfirmModal>
</ConfirmModalContext.Provider>
);
};
export default ConfirmModalProvider;

View file

@ -0,0 +1,107 @@
import { render, fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import ConfirmModalProvider from '.';
const confirmHandler = jest.fn();
const cancelHandler = jest.fn();
const ConfirmModalTestComponent = () => {
const { show } = useConfirmModal();
const onClick = async () => {
const [result] = await show({ ModalContent: 'confirm modal content' });
if (result) {
confirmHandler();
return;
}
cancelHandler();
};
return <button onClick={onClick}>show modal</button>;
};
describe('confirm modal provider', () => {
it('render confirm modal', async () => {
const { queryByText, getByText } = render(
<ConfirmModalProvider>
<ConfirmModalTestComponent />
</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();
expect(queryByText('action.cancel')).not.toBeNull();
});
});
it('confirm callback of confirm modal', async () => {
const { queryByText, getByText } = render(
<ConfirmModalProvider>
<ConfirmModalTestComponent />
</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>
<ConfirmModalTestComponent />
</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();
});
});
});

View file

@ -9,6 +9,13 @@ jest.useFakeTimers();
const sendPasscodeApi = jest.fn();
const verifyPasscodeApi = jest.fn();
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/utils', () => ({
getSendPasscodeApi: () => sendPasscodeApi,
getVerifyPasscodeApi: () => verifyPasscodeApi,

View file

@ -1,12 +1,14 @@
import classNames from 'classnames';
import { useState, useEffect, useContext, useCallback, useMemo } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useTimer } from 'react-timer-hook';
import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils';
import Passcode, { defaultLength } from '@/components/Passcode';
import TextLink from '@/components/TextLink';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
@ -34,6 +36,8 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
const [error, setError] = useState<string>();
const { setToast } = useContext(PageContext);
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { seconds, isRunning, restart } = useTimer({
autoStart: true,
@ -45,6 +49,14 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
'passcode.expired': (error) => {
setError(error.message);
},
'user.phone_not_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'user.email_not_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'passcode.code_mismatch': (error) => {
setError(error.message);
},
@ -52,7 +64,7 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
setCode([]);
},
}),
[]
[navigate, show]
);
const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(

View file

@ -37,7 +37,7 @@ describe('<EmailPasswordless/>', () => {
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('ender with terms settings but hasTerms param set to false', () => {
test('render with terms settings but hasTerms param set to false', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>

View file

@ -47,7 +47,9 @@ const TermsOfUseConfirmModal = ({ isOpen = false, onConfirm, onClose }: Props) =
setTermsAgreement(true);
onConfirm();
}}
onClose={onClose}
onClose={() => {
onClose();
}}
>
<Trans
components={{

View file

@ -0,0 +1,5 @@
import { useContext } from 'react';
import { ConfirmModalContext } from '@/containers/ConfirmModalProvider';
export const useConfirmModal = () => useContext(ConfirmModalContext);