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:
parent
bd0596f035
commit
f1ca49c892
14 changed files with 271 additions and 20 deletions
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
118
packages/ui/src/containers/ConfirmModalProvider/index.tsx
Normal file
118
packages/ui/src/containers/ConfirmModalProvider/index.tsx
Normal 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;
|
107
packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx
Normal file
107
packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -57,7 +57,7 @@ const PhonePasswordless = ({
|
|||
'user.phone_not_exists': (error) => {
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
// Directly display the error if user is trying to bind with social
|
||||
// Directly display the error if user is trying to bind with social
|
||||
if (socialToBind) {
|
||||
setToast(error.message);
|
||||
|
||||
|
|
|
@ -47,7 +47,9 @@ const TermsOfUseConfirmModal = ({ isOpen = false, onConfirm, onClose }: Props) =
|
|||
setTermsAgreement(true);
|
||||
onConfirm();
|
||||
}}
|
||||
onClose={onClose}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
components={{
|
||||
|
|
5
packages/ui/src/hooks/use-confirm-modal.ts
Normal file
5
packages/ui/src/hooks/use-confirm-modal.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { useContext } from 'react';
|
||||
|
||||
import { ConfirmModalContext } from '@/containers/ConfirmModalProvider';
|
||||
|
||||
export const useConfirmModal = () => useContext(ConfirmModalContext);
|
Loading…
Reference in a new issue