Skip to content

Commit

Permalink
feat(ui): global confirm modal (#2018)
Browse files Browse the repository at this point in the history
* feat(ui): global confirm modal

gloabl configm modal

* fix(ui): cr update

cr update
  • Loading branch information
simeng-li authored Sep 30, 2022
1 parent bd0596f commit f1ca49c
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 20 deletions.
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfirmModal/AcModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +20,6 @@ const IframeConfirmModal = ({
onConfirm,
onClose,
}: Props) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(true);

return (
Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/components/ConfirmModal/MobileModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import Button from '@/components/Button';
Expand All @@ -17,8 +16,6 @@ const MobileModal = ({
onConfirm,
onClose,
}: ModalProps) => {
const { t } = useTranslation();

return (
<ReactModal
role="dialog"
Expand All @@ -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>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/ConfirmModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 3 additions & 3 deletions packages/ui/src/components/ConfirmModal/type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { ReactNode, MouseEventHandler } from 'react';
import { TFuncKey } from 'react-i18next';

export type ModalProps = {
Expand All @@ -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;
};
15 changes: 9 additions & 6 deletions packages/ui/src/containers/AppContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
);
};

Expand Down
118 changes: 118 additions & 0 deletions packages/ui/src/containers/ConfirmModalProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 107 additions & 0 deletions packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
7 changes: 7 additions & 0 deletions packages/ui/src/containers/PasscodeValidation/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion packages/ui/src/containers/PasscodeValidation/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -45,14 +49,22 @@ 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);
},
callback: () => {
setCode([]);
},
}),
[]
[navigate, show]
);

const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading

0 comments on commit f1ca49c

Please sign in to comment.