0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(ui): refactor terms of use (#603)

* refactor(ui): refactor terms of use

refactor terms of use

* fix(ui): fix terms modal

fix terms mdoal

* fix(ui): cr fix

remove console.log
This commit is contained in:
simeng-li 2022-04-22 13:53:30 +08:00 committed by GitHub
parent e4e3fd409e
commit 3d8c3af5bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 241 additions and 141 deletions

View file

@ -37,6 +37,7 @@ const translation = {
loading: 'Loading...',
redirecting: 'Redirecting...',
agree_with_terms: 'I have read and agree to the ',
agree_with_terms_modal: 'Please read the {{terms}} and then agree the box first.',
terms_of_use: 'Terms of Use',
create_account: 'Create Account',
forgot_password: 'Forgot Password?',

View file

@ -39,6 +39,7 @@ const translation = {
loading: '读取中...',
redirecting: '页面跳转中...',
agree_with_terms: '我已阅读并同意 ',
agree_with_terms_modal: 'Please read the {{terms}} and then agree the box first.',
terms_of_use: '使用条款',
create_account: '创建账号',
forgot_password: '忘记密码?',

View file

@ -0,0 +1,21 @@
import { useContext, useEffect, ReactElement } from 'react';
import { PageContext } from '@/hooks/use-page-context';
import { SignInExperienceSettings } from '@/types';
type Props = {
settings: SignInExperienceSettings;
children: ReactElement;
};
const SettingsProvider = ({ settings, children }: Props) => {
const { setExperienceSettings } = useContext(PageContext);
useEffect(() => {
setExperienceSettings(settings);
}, [setExperienceSettings, settings]);
return children;
};
export default SettingsProvider;

View file

@ -1,3 +1,8 @@
import { Language } from '@logto/phrases';
import { BrandingStyle, SignInExperience, SignInMethodState } from '@logto/schemas';
import { SignInExperienceSettings } from '@/types';
export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4';
export const appHeadline = 'Build user identity in a modern way';
export const socialConnectors = [
@ -38,29 +43,38 @@ export const socialConnectors = [
},
];
export const mockSignInExperience = {
export const mockSignInExperience: SignInExperience = {
id: 'foo',
branding: {
primaryColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
style: 'Logo_Slogan',
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://logto.png',
slogan: 'logto',
},
termsOfUse: {
enabled: false,
enabled: true,
contentUrl: 'http://terms.of.use',
},
languageInfo: {
autoDetect: true,
fallbackLanguage: 'en',
fixedLanguage: 'zh-cn',
fallbackLanguage: Language.English,
fixedLanguage: Language.Chinese,
},
signInMethods: {
username: 'primary',
email: 'secondary',
sms: 'secondary',
social: 'secondary',
username: SignInMethodState.Primary,
email: SignInMethodState.Secondary,
sms: SignInMethodState.Secondary,
social: SignInMethodState.Secondary,
},
socialSignInConnectorIds: ['github', 'facebook'],
};
export const mockSignInExperienceSettings: SignInExperienceSettings = {
branding: mockSignInExperience.branding,
termsOfUse: mockSignInExperience.termsOfUse,
languageInfo: mockSignInExperience.languageInfo,
primarySignInMethod: 'username',
secondarySignInMethods: ['email', 'sms', 'social'],
};

View file

@ -36,6 +36,7 @@ const ConfirmModal = ({
className={classNames(modalStyles.modal, className)}
overlayClassName={modalStyles.overlay}
parentSelector={() => document.querySelector('main') ?? document.body}
ariaHideApp={false}
>
<div className={styles.container}>
<div className={styles.content}>{children}</div>

View file

@ -1,4 +1,3 @@
import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
import { render, fireEvent } from '@testing-library/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -7,10 +6,7 @@ import TermsOfUse from '.';
describe('Terms of Use', () => {
const onChange = jest.fn();
const termsOfUse: TermsOfUseType = {
enabled: true,
contentUrl: 'http://logto.dev/',
};
const contentUrl = 'http://logto.dev/';
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const prefix = t('description.agree_with_terms');
@ -20,7 +16,7 @@ describe('Terms of Use', () => {
it('render Terms of User checkbox', () => {
const { getByText, container } = render(
<TermsOfUse name="terms" termsOfUse={termsOfUse} onChange={onChange} />
<TermsOfUse name="terms" termsUrl={contentUrl} onChange={onChange} />
);
const element = getByText(prefix);
@ -33,15 +29,7 @@ describe('Terms of Use', () => {
expect(linkElement).not.toBeNull();
if (linkElement) {
expect(linkElement.href).toEqual(termsOfUse.contentUrl);
expect(linkElement.href).toEqual(contentUrl);
}
});
it('render null with disabled terms', () => {
const { container } = render(
<TermsOfUse name="terms" termsOfUse={{ ...termsOfUse, enabled: false }} onChange={onChange} />
);
expect(container.children).toHaveLength(0);
});
});

View file

@ -1,8 +1,7 @@
import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
import classNames from 'classnames';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ErrorMessage, { ErrorType } from '@/components/ErrorMessage';
import { RadioButtonIcon } from '@/components/Icons';
import TextLink from '@/components/TextLink';
@ -11,46 +10,39 @@ import * as styles from './index.module.scss';
type Props = {
name: string;
className?: string;
termsOfUse: TermsOfUseType;
termsUrl: string;
isChecked?: boolean;
error?: ErrorType;
onChange: (checked: boolean) => void;
};
const TermsOfUse = ({ name, className, termsOfUse, isChecked, error, onChange }: Props) => {
const TermsOfUse = ({ name, className, termsUrl, isChecked, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
if (!termsOfUse.enabled || !termsOfUse.contentUrl) {
return null;
}
const prefix = t('description.agree_with_terms');
return (
<div className={className}>
<div
className={styles.terms}
onClick={() => {
onChange(!isChecked);
}}
>
<input disabled readOnly name={name} type="checkbox" checked={isChecked} />
<RadioButtonIcon checked={isChecked} className={styles.radioButton} />
<div className={styles.content}>
{prefix}
<TextLink
className={styles.link}
text="description.terms_of_use"
href={termsOfUse.contentUrl}
type="secondary"
onClick={(event) => {
// Prevent above parent onClick event being triggered
event.stopPropagation();
}}
/>
</div>
<div
className={classNames(styles.terms, className)}
onClick={() => {
onChange(!isChecked);
}}
>
<input disabled readOnly name={name} type="checkbox" checked={isChecked} />
<RadioButtonIcon checked={isChecked} className={styles.radioButton} />
<div className={styles.content}>
{prefix}
<TextLink
className={styles.link}
text="description.terms_of_use"
href={termsUrl}
target="_blank"
type="secondary"
onClick={(event) => {
// Prevent above parent onClick event being triggered
event.stopPropagation();
}}
/>
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
</div>
);
};

View file

@ -0,0 +1,22 @@
import { render } from '@testing-library/react';
import React from 'react';
import TermsOfUseModal from '.';
describe('TermsOfUseModal', () => {
const onConfirm = jest.fn();
const onCancel = jest.fn();
it('render properly', () => {
const { queryByText } = render(
<TermsOfUseModal
isOpen
termsUrl="https://www.google.com"
onConfirm={onConfirm}
onClose={onCancel}
/>
);
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
});
});

View file

@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import reactStringReplace from 'react-string-replace';
import ConfirmModal from '../ConfirmModal';
import TextLink from '../TextLink';
type Props = {
isOpen?: boolean;
onConfirm: () => void;
onClose: () => void;
termsUrl: string;
};
const TermsOfUseModal = ({ isOpen = false, termsUrl, onConfirm, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const terms = t('description.terms_of_use');
const content = t('description.agree_with_terms_modal', { terms });
const modalContent: ReactNode = reactStringReplace(content, terms, () => (
<TextLink key={terms} text="description.terms_of_use" href={termsUrl} target="_blank" />
));
return (
<ConfirmModal isOpen={isOpen} onConfirm={onConfirm} onClose={onClose}>
{modalContent}
</ConfirmModal>
);
};
export default TermsOfUseModal;

View file

@ -1,23 +1,21 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import React, { ReactNode, AnchorHTMLAttributes } from 'react';
import { TFuncKey, useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
export type Props = {
export type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
className?: string;
children?: ReactNode;
text?: TFuncKey<'translation', 'main_flow'>;
href?: string;
type?: 'primary' | 'secondary';
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const TextLink = ({ className, children, text, href, type = 'primary', onClick }: Props) => {
const TextLink = ({ className, children, text, type = 'primary', ...rest }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
return (
<a className={classNames(styles.link, styles[type], className)} href={href} onClick={onClick}>
<a className={classNames(styles.link, styles[type], className)} {...rest} rel="noreferrer">
{children ?? (text ? t(text) : '')}
</a>
);

View file

@ -131,38 +131,6 @@ describe('<CreateAccount/>', () => {
expect(queryByText('passwords_do_not_match')).toBeNull();
});
test('terms of use not checked should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<CreateAccount />);
const submitButton = getByText('action.create');
const passwordInput = container.querySelector('input[name="password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm_password"]');
const usernameInput = container.querySelector('input[name="username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
expect(queryByText('agree_terms_required')).not.toBeNull();
expect(register).not.toBeCalled();
// Clear Error
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
expect(queryByText('agree_terms_required')).toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(<CreateAccount />);
const submitButton = getByText('action.create');

View file

@ -211,9 +211,8 @@ const CreateAccount = ({ className }: Props) => {
<TermsOfUse
name="termsAgreement"
className={styles.terms}
termsOfUse={{ enabled: true, contentUrl: '/' }}
termsUrl="/"
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}

View file

@ -50,24 +50,7 @@ describe('<EmailPasswordless/>', () => {
}
});
test('required terms of agreement with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailPasswordless type="sign-in" />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
fireEvent.click(submitButton);
expect(queryByText('agree_terms_required')).not.toBeNull();
});
test('signin method properly', async () => {
test('should call sign-in method properly', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailPasswordless type="sign-in" />
@ -90,7 +73,7 @@ describe('<EmailPasswordless/>', () => {
expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io');
});
test('register method properly', async () => {
test('should call register method properly', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailPasswordless type="register" />

View file

@ -140,9 +140,8 @@ const EmailPasswordless = ({ type, className }: Props) => {
<TermsOfUse
name="termsAgreement"
className={styles.terms}
termsOfUse={{ enabled: true, contentUrl: '/' }}
termsUrl="/"
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}

View file

@ -53,24 +53,7 @@ describe('<PhonePasswordless/>', () => {
}
});
test('required terms of agreement with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhonePasswordless type="sign-in" />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
fireEvent.click(submitButton);
expect(queryByText('agree_terms_required')).not.toBeNull();
});
test('signin method properly', async () => {
test('should call sign-in method properly', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhonePasswordless type="sign-in" />
@ -93,7 +76,7 @@ describe('<PhonePasswordless/>', () => {
expect(sendSignInSmsPasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
});
test('register method properly', async () => {
test('should call register method properly', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhonePasswordless type="register" />

View file

@ -143,9 +143,8 @@ const PhonePasswordless = ({ type, className }: Props) => {
<TermsOfUse
name="termsAgreement"
className={styles.terms}
termsOfUse={{ enabled: true, contentUrl: '/' }}
termsUrl="/"
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}

View file

@ -0,0 +1,45 @@
import React from 'react';
import PureTermsOfUse from '@/components/TermsOfUse';
import TermsOfUseModal from '@/components/TermsOfUseModal';
import useTerms from '@/hooks/use-terms';
type Props = {
className?: string;
};
const TermsOfUse = ({ className }: Props) => {
const { termsAgreement, setTermsAgreement, termsSettings, showTermsModal, setShowTermsModal } =
useTerms();
if (!termsSettings?.enabled || !termsSettings.contentUrl) {
return null;
}
return (
<>
<PureTermsOfUse
className={className}
name="termsAgreement"
termsUrl={termsSettings.contentUrl}
isChecked={termsAgreement}
onChange={(checked) => {
setTermsAgreement(checked);
}}
/>
<TermsOfUseModal
isOpen={showTermsModal}
termsUrl={termsSettings.contentUrl}
onConfirm={() => {
setTermsAgreement(true);
setShowTermsModal(false);
}}
onClose={() => {
setShowTermsModal(false);
}}
/>
</>
);
};
export default TermsOfUse;

View file

@ -0,0 +1,24 @@
import React from 'react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import TermsOfUse from '.';
describe('TermsOfUse Container', () => {
it('render with empty TermsOfUse settings', () => {
const { queryByText } = renderWithPageContext(<TermsOfUse />);
expect(queryByText('description.agree_with_terms')).toBeNull();
});
it('render with settings', async () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider settings={mockSignInExperienceSettings}>
<TermsOfUse />
</SettingsProvider>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
});
});

View file

@ -44,7 +44,6 @@ describe('<UsernameSignin>', () => {
fireEvent.click(submitButton);
expect(queryByText('required')).toBeNull();
expect(queryByText('agree_terms_required')).not.toBeNull();
expect(signInBasic).not.toBeCalled();
});

View file

@ -168,9 +168,8 @@ const UsernameSignin = ({ className }: Props) => {
<TermsOfUse
name="termsAgreement"
className={styles.terms}
termsOfUse={{ enabled: true, contentUrl: '/' }}
termsUrl="/"
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}

View file

@ -0,0 +1,32 @@
import { useContext, useCallback } from 'react';
import { PageContext } from './use-page-context';
const useTerms = () => {
const {
termsAgreement,
setTermsAgreement,
showTermsModal,
setShowTermsModal,
experienceSettings,
} = useContext(PageContext);
const termsValidation = useCallback(() => {
if (termsAgreement) {
return;
}
setShowTermsModal(true);
}, [setShowTermsModal, termsAgreement]);
return {
termsSettings: experienceSettings?.termsOfUse,
termsAgreement,
showTermsModal,
termsValidation,
setTermsAgreement,
setShowTermsModal,
};
};
export default useTerms;