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

Merge pull request #2137 from logto-io/simeng-log-4262

refactor(ui): refactor terms of use modals
This commit is contained in:
simeng-li 2022-10-13 14:14:10 +08:00 committed by GitHub
commit b7239af7f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 215 additions and 457 deletions

View file

@ -62,7 +62,6 @@
"react-dom": "^18.0.0",
"react-i18next": "^11.18.3",
"react-modal": "^3.15.1",
"react-modal-promise": "^1.0.2",
"react-router-dom": "^6.2.2",
"react-string-replace": "^1.0.0",
"react-timer-hook": "^3.0.5",

View file

@ -1,51 +0,0 @@
@use '@/scss/underscore' as _;
.overlay {
z-index: 100;
}
.modal {
position: absolute;
left: 20px;
right: 20px;
top: 40px;
bottom: 40px;
outline: none;
}
.container {
background: var(--color-dialogue);
border-radius: 12px;
width: 100%;
height: 100%;
@include _.flex_column(stretch, center);
}
.content {
padding: _.unit(5);
flex: 1;
@include _.flex_column;
}
iframe {
display: block;
&.hidden {
display: none;
}
}
.footer {
border-top: 1px solid var(--color-divider);
@include _.flex_row;
padding: _.unit(5);
> * {
flex: 1;
}
> button:first-child {
margin-right: _.unit(2);
}
}

View file

@ -1,63 +0,0 @@
import classNames from 'classnames';
import { useState } from 'react';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import { LoadingIcon } from '@/components/LoadingLayer';
import * as modalStyles from '../../scss/modal.module.scss';
import * as styles from './IframeConfirmModal.module.scss';
import { ModalProps } from './type';
type Props = { url: string } & Omit<ModalProps, 'children'>;
const IframeConfirmModal = ({
className,
isOpen = false,
url,
cancelText = 'action.cancel',
confirmText = 'action.confirm',
onConfirm,
onClose,
}: Props) => {
const [isLoading, setIsLoading] = useState(true);
return (
<ReactModal
role="dialog"
isOpen={isOpen}
className={classNames(styles.modal, className)}
overlayClassName={classNames(modalStyles.overlay, styles.overlay)}
>
<div className={styles.container}>
<div className={styles.content}>
{isLoading && <LoadingIcon />}
<iframe
sandbox={undefined}
className={isLoading ? styles.hidden : undefined}
// For styling use
// eslint-disable-next-line jsx-a11y/aria-role
role="iframe"
src={url}
title="terms"
frameBorder="0"
width="100%"
height="100%"
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
/>
</div>
<div className={styles.footer}>
<Button title={cancelText} type="secondary" onClick={onClose} />
<Button title={confirmText} onClick={onConfirm ?? onClose} />
</div>
</div>
</ReactModal>
);
};
export default IframeConfirmModal;

View file

@ -37,3 +37,29 @@
margin-right: _.unit(2);
}
}
.iframeModal {
top: 40px;
bottom: 40px;
transform: none;
.container {
padding: 0;
border-radius: 12px;
width: 100%;
height: 100%;
@include _.flex_column(stretch, center);
}
.content {
padding: _.unit(5);
flex: 1;
@include _.flex_column;
}
.footer {
margin-top: 0;
border-top: 1px solid var(--color-divider);
padding: _.unit(5);
}
}

View file

@ -1,6 +1,4 @@
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,37 +0,0 @@
import { fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import TermsOfUseConfirmModal from '@/containers/TermsOfUse/TermsOfUseConfirmModal';
import { modalPromisify } from '.';
describe('modalPromisify', () => {
const onResolve = jest.fn();
const onReject = jest.fn();
it('resolve properly', () => {
const PromisifyModal = modalPromisify(TermsOfUseConfirmModal);
const { getByText } = renderWithPageContext(
<PromisifyModal isOpen instanceId="foo" onResolve={onResolve} onReject={onReject} />
);
const confirmButton = getByText('action.agree');
fireEvent.click(confirmButton);
expect(onResolve).toBeCalled();
});
it('reject', () => {
const PromisifyModal = modalPromisify(TermsOfUseConfirmModal);
const { getByText } = renderWithPageContext(
<PromisifyModal isOpen instanceId="foo" onResolve={onResolve} onReject={onReject} />
);
const cancelButton = getByText('action.cancel');
fireEvent.click(cancelButton);
expect(onReject).toBeCalled();
});
});

View file

@ -1,29 +0,0 @@
import { InstanceProps } from 'react-modal-promise';
import { ConfirmModalMessage } from '@/types';
type Props = {
isOpen?: boolean;
onConfirm: () => void;
onClose: (message?: ConfirmModalMessage) => void;
};
export const modalPromisify =
(ConfirmModal: (props: Props) => JSX.Element) =>
({
isOpen,
onResolve,
onReject,
}: Omit<InstanceProps<boolean | ConfirmModalMessage>, 'open' | 'close'>) => {
return (
<ConfirmModal
isOpen={isOpen}
onConfirm={() => {
onResolve(true);
}}
onClose={(message?: ConfirmModalMessage) => {
onReject(message ?? false);
}}
/>
);
};

View file

@ -4,7 +4,7 @@ import { useState, useRef, useMemo, createContext, useCallback } from 'react';
import { WebModal, MobileModal, ModalProps } from '@/components/ConfirmModal';
import usePlatform from '@/hooks/use-platform';
export type ChildRenderProps = {
export type ModalContentRenderProps = {
confirm: (data?: unknown) => void;
cancel: (data?: unknown) => void;
};
@ -12,7 +12,7 @@ export type ChildRenderProps = {
type ConfirmModalType = 'alert' | 'confirm';
type ConfirmModalState = Omit<ModalProps, 'onClose' | 'onConfirm' | 'children'> & {
ModalContent: string | ((props: ChildRenderProps) => Nullable<JSX.Element>);
ModalContent: string | ((props: ModalContentRenderProps) => Nullable<JSX.Element>);
type: ConfirmModalType;
};

View file

@ -0,0 +1,7 @@
.hidden {
display: none;
}
.iframe {
display: block;
}

View file

@ -0,0 +1,39 @@
import { useState } from 'react';
import { LoadingIcon } from '@/components/LoadingLayer';
import * as styles from './index.module.scss';
type Props = { url?: string; title?: string };
const IframeConfirmModalContent = ({ url, title }: Props) => {
const [isLoading, setIsLoading] = useState(true);
return (
<>
{isLoading && <LoadingIcon />}
<iframe
role="article"
sandbox={undefined}
className={isLoading ? styles.hidden : styles.iframe}
src={url}
title={title}
frameBorder="0"
width="100%"
height="100%"
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
/>
</>
);
};
export default IframeConfirmModalContent;
export const createIframeConfirmModalContent = (url?: string, title?: string) => (
<IframeConfirmModalContent url={url} title={title} />
);

View file

@ -1,16 +0,0 @@
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import TermsOfUseModal from '.';
describe('TermsOfUseModal', () => {
const onConfirm = jest.fn();
const onCancel = jest.fn();
it('render properly', () => {
const { queryByText } = renderWithPageContext(
<TermsOfUseModal isOpen onConfirm={onConfirm} onClose={onCancel} />
);
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
});
});

View file

@ -1,67 +0,0 @@
import { useContext } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { create } from 'react-modal-promise';
import { WebModal, MobileModal, modalPromisify } from '@/components/ConfirmModal';
import TextLink from '@/components/TextLink';
import { PageContext } from '@/hooks/use-page-context';
import usePlatform from '@/hooks/use-platform';
import { ConfirmModalMessage } from '@/types';
/**
* For web use only confirm modal, does not contain Terms iframe
*/
type Props = {
isOpen?: boolean;
onConfirm: () => void;
onClose: (message?: ConfirmModalMessage) => void;
};
const TermsOfUseConfirmModal = ({ isOpen = false, onConfirm, onClose }: Props) => {
const { t } = useTranslation();
const { isMobile } = usePlatform();
const { setTermsAgreement, experienceSettings } = useContext(PageContext);
const { termsOfUse } = experienceSettings ?? {};
const ConfirmModal = isMobile ? MobileModal : WebModal;
const terms = t('description.terms_of_use');
const linkProps = isMobile
? {
onClick: () => {
onClose(ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL);
},
}
: {
href: termsOfUse?.contentUrl,
target: '_blank',
};
return (
<ConfirmModal
isOpen={isOpen}
confirmText="action.agree"
onConfirm={() => {
setTermsAgreement(true);
onConfirm();
}}
onClose={() => {
onClose();
}}
>
<Trans
components={{
link: <TextLink key={terms} text="description.terms_of_use" {...linkProps} />,
}}
>
{t('description.agree_with_terms_modal')}
</Trans>
</ConfirmModal>
);
};
export default TermsOfUseConfirmModal;
export const termsOfUseConfirmModalPromise = create(modalPromisify(TermsOfUseConfirmModal));

View file

@ -0,0 +1,45 @@
import { useContext } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import TextLink from '@/components/TextLink';
import { ModalContentRenderProps } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import usePlatform from '@/hooks/use-platform';
import { ConfirmModalMessage } from '@/types';
const TermsOfUseConfirmModalContent = ({ cancel }: ModalContentRenderProps) => {
const { experienceSettings } = useContext(PageContext);
const { termsOfUse } = experienceSettings ?? {};
const { t } = useTranslation();
const { isMobile } = usePlatform();
const linkProps = isMobile
? {
onClick: () => {
cancel(ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL);
},
}
: {
href: termsOfUse?.contentUrl,
target: '_blank',
};
return (
<Trans
components={{
link: (
<TextLink
key={t('description.terms_of_use')}
text="description.terms_of_use"
{...linkProps}
/>
),
}}
>
{t('description.agree_with_terms_modal')}
</Trans>
);
};
export default TermsOfUseConfirmModalContent;

View file

@ -1,36 +0,0 @@
import { screen, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import TermsOfUseIframeModal from '.';
describe('TermsOfUseModal', () => {
const onConfirm = jest.fn();
const onCancel = jest.fn();
it('render properly', () => {
const { queryByText, getByText } = renderWithPageContext(
<SettingsProvider>
<TermsOfUseIframeModal isOpen onConfirm={onConfirm} onClose={onCancel} />
</SettingsProvider>
);
expect(queryByText('action.agree')).not.toBeNull();
const iframe = screen.queryByRole('iframe');
expect(iframe).not.toBeNull();
if (iframe) {
expect(iframe).toHaveProperty('src', mockSignInExperienceSettings.termsOfUse.contentUrl);
}
const confirmButton = getByText('action.agree');
fireEvent.click(confirmButton);
expect(onConfirm).toBeCalled();
});
});

View file

@ -1,36 +0,0 @@
import { useContext } from 'react';
import { create } from 'react-modal-promise';
import { IframeModal, modalPromisify } from '@/components/ConfirmModal';
import { PageContext } from '@/hooks/use-page-context';
/**
* For mobile use only, includes embedded Terms iframe
*/
type Props = {
isOpen?: boolean;
onConfirm: () => void;
onClose: () => void;
};
const TermsOfUseIframeModal = ({ isOpen = false, onConfirm, onClose }: Props) => {
const { setTermsAgreement, experienceSettings } = useContext(PageContext);
const { termsOfUse } = experienceSettings ?? {};
return (
<IframeModal
isOpen={isOpen}
confirmText="action.agree"
url={termsOfUse?.contentUrl ?? ''}
onConfirm={() => {
setTermsAgreement(true);
onConfirm();
}}
onClose={onClose}
/>
);
};
export default TermsOfUseIframeModal;
export const termsOfUseIframeModalPromise = create(modalPromisify(TermsOfUseIframeModal));

View file

@ -1,5 +1,3 @@
import ModalContainer from 'react-modal-promise';
import TermsOfUseComponent from '@/components/TermsOfUse';
import usePlatform from '@/hooks/use-platform';
import useTerms from '@/hooks/use-terms';
@ -18,19 +16,16 @@ const TermsOfUse = ({ className }: Props) => {
}
return (
<>
<TermsOfUseComponent
className={className}
name="termsAgreement"
termsUrl={termsSettings.contentUrl}
isChecked={termsAgreement}
onChange={(checked) => {
setTermsAgreement(checked);
}}
onTermsClick={isMobile ? termsOfUseIframeModalHandler : undefined}
/>
<ModalContainer />
</>
<TermsOfUseComponent
className={className}
name="termsAgreement"
termsUrl={termsSettings.contentUrl}
isChecked={termsAgreement}
onChange={(checked) => {
setTermsAgreement(checked);
}}
onTermsClick={isMobile ? termsOfUseIframeModalHandler : undefined}
/>
);
};

View file

@ -4,22 +4,14 @@ import { act } from 'react-dom/test-utils';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { signInBasic } from '@/apis/sign-in';
import { termsOfUseConfirmModalPromise } from '@/containers/TermsOfUse/TermsOfUseConfirmModal';
import { termsOfUseIframeModalPromise } from '@/containers/TermsOfUse/TermsOfUseIframeModal';
import { ConfirmModalMessage } from '@/types';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import UsernameSignIn from '.';
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => 0) }));
jest.mock('@/containers/TermsOfUse/TermsOfUseConfirmModal', () => ({
termsOfUseConfirmModalPromise: jest.fn().mockResolvedValue(true),
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('@/containers/TermsOfUse/TermsOfUseIframeModal', () => ({
termsOfUseIframeModalPromise: jest.fn().mockResolvedValue(true),
}));
const termsOfUseConfirmModalPromiseMock = termsOfUseConfirmModalPromise as jest.Mock;
const termsOfUseIframeModalPromiseMock = termsOfUseIframeModalPromise as jest.Mock;
describe('<UsernameSignIn>', () => {
afterEach(() => {
@ -70,41 +62,11 @@ describe('<UsernameSignIn>', () => {
});
test('should show terms confirm modal', async () => {
const { getByText, container } = renderWithPageContext(
const { queryByText, getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameSignIn />
</SettingsProvider>
);
const submitButton = getByText('action.sign_in');
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: 'password' } });
}
await act(async () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(termsOfUseConfirmModalPromiseMock).toBeCalled();
});
});
});
test('should show terms detail modal', async () => {
termsOfUseConfirmModalPromiseMock.mockRejectedValue(
ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL
);
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameSignIn />
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
);
const submitButton = getByText('action.sign_in');
@ -124,10 +86,49 @@ describe('<UsernameSignIn>', () => {
fireEvent.click(submitButton);
});
expect(signInBasic).not.toBeCalled();
await waitFor(() => {
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
});
});
test('should show terms detail modal', async () => {
const { getByText, queryByText, container, queryByRole } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
);
const submitButton = getByText('action.sign_in');
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: 'password' } });
}
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(termsOfUseIframeModalPromiseMock).toBeCalled();
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
});
const termsLink = getByText('description.terms_of_use');
act(() => {
fireEvent.click(termsLink);
});
await waitFor(() => {
expect(queryByText('action.agree')).not.toBeNull();
expect(queryByRole('article')).not.toBeNull();
});
});

View file

@ -2,4 +2,6 @@ import { useContext } from 'react';
import { ConfirmModalContext } from '@/containers/ConfirmModalProvider';
export type { ModalContentRenderProps } from '@/containers/ConfirmModalProvider';
export const useConfirmModal = () => useContext(ConfirmModalContext);

View file

@ -1,34 +1,54 @@
import { useContext, useCallback } from 'react';
import { termsOfUseConfirmModalPromise } from '@/containers/TermsOfUse/TermsOfUseConfirmModal';
import { termsOfUseIframeModalPromise } from '@/containers/TermsOfUse/TermsOfUseIframeModal';
import { createIframeConfirmModalContent } from '@/containers/TermsOfUse/IframeConfirmModalContent';
import TermsOfUseConfirmModalContent from '@/containers/TermsOfUse/TermsOfUseConfirmModalContent';
import { ConfirmModalMessage } from '@/types';
import { flattenPromiseResult } from '@/utils/promisify';
import * as styles from '../components/ConfirmModal/MobileModal.module.scss';
import { useConfirmModal } from './use-confirm-modal';
import { PageContext } from './use-page-context';
const useTerms = () => {
const { termsAgreement, setTermsAgreement, experienceSettings } = useContext(PageContext);
const { show } = useConfirmModal();
const { termsOfUse } = experienceSettings ?? {};
const termsOfUseIframeModalHandler = useCallback(async () => {
const [result] = await flattenPromiseResult<boolean>(termsOfUseIframeModalPromise());
const [result] = await show({
className: styles.iframeModal,
ModalContent: () => createIframeConfirmModalContent(termsOfUse?.contentUrl),
confirmText: 'action.agree',
});
return Boolean(result);
}, []);
const termsOfUseConfirmModalHandler = useCallback(async () => {
const [result, error] = await flattenPromiseResult<boolean>(termsOfUseConfirmModalPromise());
if (error === ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL) {
const result = await termsOfUseIframeModalHandler();
return result;
// Update the local terms status
if (result) {
setTermsAgreement(true);
}
return Boolean(result);
}, [termsOfUseIframeModalHandler]);
return result;
}, [setTermsAgreement, show, termsOfUse?.contentUrl]);
const termsOfUseConfirmModalHandler = useCallback(async () => {
const [result, data] = await show({
ModalContent: TermsOfUseConfirmModalContent,
confirmText: 'action.agree',
});
// Show Terms Detail Confirm Modal
if (data === ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL) {
const detailResult = await termsOfUseIframeModalHandler();
return detailResult;
}
// Update the local terms status
if (result) {
setTermsAgreement(true);
}
return result;
}, [setTermsAgreement, show, termsOfUseIframeModalHandler]);
const termsValidation = useCallback(async () => {
if (termsAgreement || !termsOfUse?.enabled || !termsOfUse.contentUrl) {

View file

@ -1,18 +0,0 @@
import { flattenPromiseResult } from './promisify';
describe('promisify', () => {
test('flattenPromiseResult', async () => {
const promiseResolve = Promise.resolve('resolved');
const [result, rejection] = await flattenPromiseResult(promiseResolve);
expect(result).toBe('resolved');
expect(rejection).toBeUndefined();
// eslint-disable-next-line prefer-promise-reject-errors
const promiseRejection = Promise.reject('rejected');
const [_result, _rejection] = await flattenPromiseResult(promiseRejection);
expect(_result).toBeUndefined();
expect(_rejection).toBe('rejected');
});
});

View file

@ -1,9 +0,0 @@
export const flattenPromiseResult = async <T>(promise: Promise<T>): Promise<[T?, unknown?]> => {
try {
const result = await promise;
return [result];
} catch (error: unknown) {
return [undefined, error];
}
};

View file

@ -692,7 +692,6 @@ importers:
react-dom: ^18.0.0
react-i18next: ^11.18.3
react-modal: ^3.15.1
react-modal-promise: ^1.0.2
react-router-dom: ^6.2.2
react-string-replace: ^1.0.0
react-timer-hook: ^3.0.5
@ -746,7 +745,6 @@ importers:
react-dom: 18.2.0_react@18.2.0
react-i18next: 11.18.3_shxxmfhtk2bc4pbx5cyq3uoph4
react-modal: 3.15.1_biqbaboplfbrettd7655fr4n2y
react-modal-promise: 1.0.2_biqbaboplfbrettd7655fr4n2y
react-router-dom: 6.2.2_biqbaboplfbrettd7655fr4n2y
react-string-replace: 1.0.0
react-timer-hook: 3.0.5_biqbaboplfbrettd7655fr4n2y
@ -13344,16 +13342,6 @@ packages:
- supports-color
dev: true
/react-modal-promise/1.0.2_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-dqT618ROhG8qh1+O6EZkia5ELw3zaZWGpMX2YfEH4bgwYENPuFonqKw1W70LFx3K/SCZvVBcD6UYEI12yzYXzg==}
peerDependencies:
react: '>=16.8.0 || ^18.0.0'
react-dom: '>=16.8.0'
dependencies:
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: true
/react-modal/3.15.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-duB9bxOaYg7Zt6TMFldIFxQRtSP+Dg3F1ZX3FXxSUn+3tZZ/9JCgeAQKDg7rhZSAqopq8TFRw3yIbnx77gyFTw==}
engines: {node: '>=8'}