0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(ui): add SmsRegister Container (#2299)

This commit is contained in:
simeng-li 2022-11-02 15:38:14 +08:00 committed by GitHub
parent 55f2ba9c20
commit 14b97b93f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 534 additions and 49 deletions

View file

@ -70,7 +70,7 @@ describe('<EmailForm/>', () => {
});
test('should display and clear the form error message as expected', () => {
const { queryByText, container, getByText } = renderWithPageContext(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<EmailForm
errorMessage="form error"

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useCallback, useEffect, useRef } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
@ -44,20 +44,6 @@ const EmailForm = ({
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
/* Clear the form error when input field is updated */
const errorMessageRef = useRef(errorMessage);
useEffect(() => {
// eslint-disable-next-line @silverhand/fp/no-mutation
errorMessageRef.current = errorMessage;
}, [errorMessage]);
useEffect(() => {
if (errorMessageRef.current) {
clearErrorMessage?.();
}
}, [clearErrorMessage, errorMessageRef, fieldValue.email]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
@ -75,6 +61,8 @@ const EmailForm = ({
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue.email]
);
const { onChange, ...rest } = register('email', emailValidation);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
@ -86,7 +74,11 @@ const EmailForm = ({
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}
onChange={(event) => {
onChange(event);
clearErrorMessage?.();
}}
{...rest}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}

View file

@ -0,0 +1,178 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PhoneForm from './PhoneForm';
const onSubmit = jest.fn();
const clearErrorMessage = jest.fn();
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<PhonePasswordless/>', () => {
afterEach(() => {
jest.clearAllMocks();
});
const phoneNumber = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
test('render', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<PhoneForm onSubmit={onSubmit} />
</MemoryRouter>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('render with terms settings', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with terms settings but hasTerms param set to false', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).toBeNull();
});
test('required phone with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneForm onSubmit={onSubmit} />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
fireEvent.click(submitButton);
expect(queryByText('invalid_phone')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: '1113' } });
expect(queryByText('invalid_phone')).not.toBeNull();
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
expect(queryByText('invalid_phone')).toBeNull();
}
});
test('should display and clear the form error message as expected', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<PhoneForm
errorMessage="form error"
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
</MemoryRouter>
);
expect(queryByText('form error')).not.toBeNull();
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
expect(clearErrorMessage).toBeCalled();
}
});
test('should blocked by terms validation with terms settings enabled', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).not.toBeCalled();
});
});
test('should call submit method properly with terms settings enabled but hasTerms param set to false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
});
});
test('should call submit method properly with terms settings enabled and checked', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
});
});
});

View file

@ -0,0 +1,115 @@
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { PhoneInput } from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasTerms?: boolean;
hasSwitch?: boolean;
errorMessage?: string;
clearErrorMessage?: () => void;
onSubmit: (phone: string) => Promise<void>;
};
type FieldState = {
phone: string;
};
const defaultState: FieldState = { phone: '' };
const PhoneForm = ({
autoFocus,
hasTerms = true,
hasSwitch = false,
className,
errorMessage,
clearErrorMessage,
onSubmit,
}: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const { fieldValue, setFieldValue, validateForm, register } = useForm(defaultState);
// Validate phoneNumber with given country code
const phoneNumberValidation = useCallback(
(phoneNumber: string) => {
if (!isValidPhoneNumber(phoneNumber)) {
return 'invalid_phone';
}
},
[isValidPhoneNumber]
);
// Sync phoneNumber
useEffect(() => {
setFieldValue((previous) => ({
...previous,
phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`,
}));
}, [phoneNumber, setFieldValue]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!validateForm()) {
return;
}
if (hasTerms && !(await termsValidation())) {
return;
}
await onSubmit(fieldValue.phone);
},
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue.phone]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PhoneInput
name="phone"
placeholder={t('input.phone_number')}
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
clearErrorMessage?.();
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default PhoneForm;

View file

@ -0,0 +1,23 @@
import PhoneForm from './PhoneForm';
import useSmsRegister from './use-sms-register';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
const SmsRegister = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSmsRegister();
return (
<PhoneForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
export default SmsRegister;

View file

@ -0,0 +1,24 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.terms,
.switch {
margin-bottom: _.unit(4);
}
.switch {
display: block;
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1 @@
export { default as SmsRegister } from './SmsRegister';

View file

@ -0,0 +1,55 @@
import { SignInIdentifier } from '@logto/schemas';
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sendRegisterSmsPasscode } from '@/apis/register';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { UserFlow } from '@/types';
const useSmsRegister = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const navigate = useNavigate();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'guard.invalid_input': () => {
setErrorMessage('invalid_phone');
},
}),
[setErrorMessage]
);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const { run: asyncSendRegisterSmsPasscode } = useApi(sendRegisterSmsPasscode, errorHandlers);
const onSubmit = useCallback(
async (phone: string) => {
const result = await asyncSendRegisterSmsPasscode(phone);
if (!result) {
return;
}
navigate(
{
pathname: `/${UserFlow.register}/${SignInIdentifier.Sms}/passcode-validation`,
search: location.search,
},
{ state: { phone } }
);
},
[asyncSendRegisterSmsPasscode, navigate]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useSmsRegister;

View file

@ -4,9 +4,11 @@ import { PageContext } from './use-page-context';
export const useSieMethods = () => {
const { experienceSettings } = useContext(PageContext);
const { methods, password, verify } = experienceSettings?.signUp ?? {};
return {
signUpMethods: experienceSettings?.signUp.methods ?? [],
signUpMethods: methods ?? [],
signUpSettings: { password, verify },
signInMethods: experienceSettings?.signIn.methods ?? [],
socialConnectors: experienceSettings?.socialConnectors ?? [],
};

View file

@ -1,10 +1,9 @@
import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailRegister } from '@/containers/EmailForm';
import { PhonePasswordless } from '@/containers/Passwordless';
import { SmsRegister } from '@/containers/PhoneForm';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import { UserFlow } from '@/types';
import * as styles from './index.module.scss';
@ -19,7 +18,7 @@ const Main = ({ signUpMethod, socialConnectors }: Props) => {
return <EmailRegister className={styles.main} />;
case 'sms':
return <PhonePasswordless type={UserFlow.register} className={styles.main} />;
return <SmsRegister className={styles.main} />;
case 'username':
return <UsernameRegister className={styles.main} />;

View file

@ -1,6 +1,9 @@
import { render } from '@testing-library/react';
import { SignInIdentifier } from '@logto/schemas';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SecondaryRegister from '@/pages/SecondaryRegister';
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => 0) }));
@ -9,21 +12,26 @@ jest.mock('i18next', () => ({
}));
describe('<SecondaryRegister />', () => {
test('renders without exploding', async () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/register']}>
<SecondaryRegister />
</MemoryRouter>
);
expect(queryByText('action.create_account')).not.toBeNull();
expect(queryByText('action.create')).not.toBeNull();
});
test('renders phone', async () => {
const { queryByText, container } = render(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/sms']}>
<Routes>
<Route path="/register/:method" element={<SecondaryRegister />} />
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Sms],
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
@ -32,10 +40,25 @@ describe('<SecondaryRegister />', () => {
});
test('renders email', async () => {
const { queryByText, container } = render(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route path="/register/:method" element={<SecondaryRegister />} />
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Email],
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
@ -43,11 +66,65 @@ describe('<SecondaryRegister />', () => {
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
test('renders non-recognized method', async () => {
const { queryByText } = render(
test('renders non-recognized method should return error page', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/test']}>
<Routes>
<Route path="/register/:method" element={<SecondaryRegister />} />
<Route
path="/register/:method"
element={
<SettingsProvider>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('renders non-supported signUp methods should return error page', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render non-verified passwordless methods should return error page', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
methods: [SignInIdentifier.Email],
password: true,
verify: false,
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);

View file

@ -1,35 +1,48 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { is } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import CreateAccount from '@/containers/CreateAccount';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import { EmailRegister } from '@/containers/EmailForm';
import { SmsRegister } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
import { SignInMethodGuard, passcodeMethodGuard } from '@/types/guard';
type Parameters = {
method?: string;
};
const SecondaryRegister = () => {
const { method = 'username' } = useParams<Parameters>();
const { method = '' } = useParams<Parameters>();
const { signUpMethods, signUpSettings } = useSieMethods();
const registerForm = useMemo(() => {
if (method === 'sms') {
if (method === SignInIdentifier.Sms) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <PhonePasswordless autoFocus type={UserFlow.register} />;
return <SmsRegister autoFocus />;
}
if (method === 'email') {
if (method === SignInIdentifier.Email) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailPasswordless autoFocus type={UserFlow.register} />;
return <EmailRegister autoFocus />;
}
// eslint-disable-next-line jsx-a11y/no-autofocus
return <CreateAccount autoFocus />;
if (method === SignInIdentifier.Username) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <CreateAccount autoFocus />;
}
}, [method]);
if (!['email', 'sms', 'username'].includes(method)) {
// Validate the signUp method
if (!is(method, SignInMethodGuard) || !signUpMethods.includes(method)) {
return <ErrorPage />;
}
// Validate the verify settings
if (is(method, passcodeMethodGuard) && !signUpSettings.verify) {
return <ErrorPage />;
}

View file

@ -15,6 +15,12 @@ export const passcodeMethodGuard = s.union([
s.literal(SignInIdentifier.Sms),
]);
export const SignInMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Sms),
s.literal(SignInIdentifier.Username),
]);
export const userFlowGuard = s.union([
s.literal('sign-in'),
s.literal('register'),