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:
parent
a4d8d31e98
commit
55f2ba9c20
11 changed files with 385 additions and 5 deletions
172
packages/ui/src/containers/EmailForm/EmailForm.test.tsx
Normal file
172
packages/ui/src/containers/EmailForm/EmailForm.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
104
packages/ui/src/containers/EmailForm/EmailForm.tsx
Normal file
104
packages/ui/src/containers/EmailForm/EmailForm.tsx
Normal 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;
|
23
packages/ui/src/containers/EmailForm/EmailRegister.tsx
Normal file
23
packages/ui/src/containers/EmailForm/EmailRegister.tsx
Normal 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;
|
24
packages/ui/src/containers/EmailForm/index.module.scss
Normal file
24
packages/ui/src/containers/EmailForm/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
1
packages/ui/src/containers/EmailForm/index.tsx
Normal file
1
packages/ui/src/containers/EmailForm/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as EmailRegister } from './EmailRegister';
|
55
packages/ui/src/containers/EmailForm/use-email-register.ts
Normal file
55
packages/ui/src/containers/EmailForm/use-email-register.ts
Normal 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;
|
|
@ -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 = {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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', () => {
|
|
@ -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} />;
|
||||
|
|
Loading…
Reference in a new issue