0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(ui): introduce EmailRegister (#2296)

This commit is contained in:
simeng-li 2022-11-02 14:58:19 +08:00 committed by GitHub
parent a4d8d31e98
commit 55f2ba9c20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 385 additions and 5 deletions

View file

@ -0,0 +1,172 @@
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 EmailForm from './EmailForm';
const onSubmit = jest.fn();
const clearErrorMessage = jest.fn();
describe('<EmailForm/>', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('render', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<EmailForm onSubmit={onSubmit} />
</MemoryRouter>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('render with terms settings', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm 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>
<EmailForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).toBeNull();
});
test('required email with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailForm onSubmit={onSubmit} />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
fireEvent.click(submitButton);
expect(queryByText('invalid_email')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo' } });
expect(queryByText('invalid_email')).not.toBeNull();
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
expect(queryByText('invalid_email')).toBeNull();
}
});
test('should display and clear the form error message as expected', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailForm
errorMessage="form error"
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
</MemoryRouter>
);
expect(queryByText('form error')).not.toBeNull();
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo' } });
expect(clearErrorMessage).toBeCalled();
}
});
test('should blocked by terms validation with terms settings enabled', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).not.toBeCalled();
});
});
test('should call onSubmit properly with terms settings enabled but hasTerms param set to false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith('foo@logto.io');
});
});
test('should call onSubmit method properly with terms settings enabled and checked', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith('foo@logto.io');
});
});
});

View file

@ -0,0 +1,104 @@
import classNames from 'classnames';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { emailValidation } from '@/utils/field-validations';
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: (email: string) => Promise<void>;
};
type FieldState = {
email: string;
};
const defaultState: FieldState = { email: '' };
const EmailForm = ({
autoFocus,
hasTerms = true,
hasSwitch = false,
errorMessage,
clearErrorMessage,
className,
onSubmit,
}: Props) => {
const { t } = useTranslation();
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();
if (!validateForm()) {
return;
}
if (hasTerms && !(await termsValidation())) {
return;
}
await onSubmit(fieldValue.email);
},
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue.email]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default EmailForm;

View file

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

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 EmailRegister } from './EmailRegister';

View file

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

View file

@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import Input from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
@ -14,7 +15,6 @@ import useTerms from '@/hooks/use-terms';
import type { UserFlow } from '@/types';
import { emailValidation } from '@/utils/field-validations';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
type Props = {

View file

@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import { PhoneInput } from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
@ -14,7 +15,6 @@ import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import type { UserFlow } from '@/types';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
type Props = {

View file

@ -2,7 +2,7 @@ import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import PasswordlessSwitch from './PasswordlessSwitch';
import PasswordlessSwitch from '.';
describe('<PasswordlessSwitch />', () => {
test('render sms passwordless switch', () => {

View file

@ -1,6 +1,7 @@
import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import { EmailRegister } from '@/containers/EmailForm';
import { PhonePasswordless } from '@/containers/Passwordless';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import { UserFlow } from '@/types';
@ -15,7 +16,7 @@ type Props = {
const Main = ({ signUpMethod, socialConnectors }: Props) => {
switch (signUpMethod) {
case 'email':
return <EmailPasswordless type={UserFlow.register} className={styles.main} />;
return <EmailRegister className={styles.main} />;
case 'sms':
return <PhonePasswordless type={UserFlow.register} className={styles.main} />;