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

refactor(ui): simplify the usage of inputField error message (#3081)

This commit is contained in:
simeng-li 2023-02-14 14:50:34 +08:00 committed by GitHub
parent 2b7da7b228
commit 4f4c444442
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 64 additions and 107 deletions

View file

@ -31,7 +31,7 @@ describe('InputField Component', () => {
test('render error message', () => {
const errorCode = 'invalid_email';
const { queryByText } = render(<InputField error={errorCode} />);
const { queryByText } = render(<InputField errorMessage={errorCode} />);
expect(queryByText(errorCode)).not.toBeNull();
});

View file

@ -2,14 +2,13 @@ import classNames from 'classnames';
import type { ForwardedRef, HTMLProps, ReactElement } from 'react';
import { forwardRef, cloneElement } from 'react';
import type { ErrorType } from '@/components/ErrorMessage';
import ErrorMessage from '@/components/ErrorMessage';
import * as styles from './index.module.scss';
export type Props = Omit<HTMLProps<HTMLInputElement>, 'prefix'> & {
className?: string;
error?: ErrorType;
errorMessage?: string;
isDanger?: boolean;
prefix?: ReactElement;
isPrefixVisible?: boolean;
@ -21,7 +20,7 @@ export type Props = Omit<HTMLProps<HTMLInputElement>, 'prefix'> & {
const InputField = (
{
className,
error,
errorMessage,
isDanger,
prefix,
suffix,
@ -49,7 +48,7 @@ const InputField = (
className: classNames([suffix.props.className, styles.suffix]),
})}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
{errorMessage && <ErrorMessage className={styles.errorMessage}>{errorMessage}</ErrorMessage>}
</div>
);

View file

@ -31,7 +31,7 @@ describe('Input Field UI Component', () => {
test('render error message', () => {
const errorCode = 'password_required';
const { queryByText } = render(<PasswordInputField error={errorCode} />);
const { queryByText } = render(<PasswordInputField errorMessage={errorCode} />);
expect(queryByText(errorCode)).not.toBeNull();
});

View file

@ -6,7 +6,6 @@ import { useImperativeHandle, useRef, forwardRef } from 'react';
import ClearIcon from '@/assets/icons/clear-icon.svg';
import IconButton from '@/components/Button/IconButton';
import type { ErrorType } from '@/components/ErrorMessage';
import InputField from '../InputField';
import AnimatedPrefix from './AnimatedPrefix';
@ -19,7 +18,7 @@ export type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-in
type Props = Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'prefix'> & {
className?: string;
error?: ErrorType;
errorMessage?: string;
isDanger?: boolean;
enabledTypes?: EnabledIdentifierTypes;

View file

@ -34,7 +34,7 @@ describe('<SetPassword />', () => {
expect(clearError).toBeCalled();
await waitFor(() => {
expect(queryByText('password_required')).not.toBeNull();
expect(queryByText('error.password_required')).not.toBeNull();
});
expect(submit).not.toBeCalled();
@ -54,7 +54,7 @@ describe('<SetPassword />', () => {
});
await waitFor(() => {
expect(queryByText('password_min_length')).not.toBeNull();
expect(queryByText('error.password_min_length')).not.toBeNull();
});
expect(submit).not.toBeCalled();
@ -67,7 +67,7 @@ describe('<SetPassword />', () => {
});
await waitFor(() => {
expect(queryByText('password_min_length')).toBeNull();
expect(queryByText('error.password_min_length')).toBeNull();
});
});
@ -90,7 +90,7 @@ describe('<SetPassword />', () => {
});
await waitFor(() => {
expect(queryByText('passwords_do_not_match')).not.toBeNull();
expect(queryByText('error.passwords_do_not_match')).not.toBeNull();
});
expect(submit).not.toBeCalled();
@ -103,7 +103,7 @@ describe('<SetPassword />', () => {
});
await waitFor(() => {
expect(queryByText('passwords_do_not_match')).toBeNull();
expect(queryByText('error.passwords_do_not_match')).toBeNull();
});
});
@ -125,7 +125,7 @@ describe('<SetPassword />', () => {
fireEvent.submit(submitButton);
});
expect(queryByText('passwords_do_not_match')).toBeNull();
expect(queryByText('error.passwords_do_not_match')).toBeNull();
await waitFor(() => {
expect(submit).toBeCalledWith('123456');

View file

@ -8,7 +8,6 @@ import Button from '@/components/Button';
import IconButton from '@/components/Button/IconButton';
import ErrorMessage from '@/components/ErrorMessage';
import { InputField } from '@/components/InputFields';
import { passwordErrorWatcher } from '@/utils/form';
import TogglePassword from './TogglePassword';
import * as styles from './index.module.scss';
@ -61,21 +60,24 @@ const SetPassword = ({
[clearErrorMessage, handleSubmit, onSubmit]
);
const newPasswordError = passwordErrorWatcher(errors.newPassword);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<InputField
required
className={styles.inputField}
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.password')}
autoFocus={autoFocus}
isDanger={!!newPasswordError}
error={newPasswordError}
aria-invalid={!!newPasswordError}
{...register('newPassword', { required: true, minLength: 6 })}
isDanger={!!errors.newPassword}
errorMessage={errors.newPassword?.message}
aria-invalid={!!errors.newPassword}
{...register('newPassword', {
required: t('error.password_required'),
minLength: {
value: 6,
message: t('error.password_min_length', { length: 6 }),
},
})}
isSuffixFocusVisible={!!watch('newPassword')}
suffix={
<IconButton
@ -89,15 +91,14 @@ const SetPassword = ({
/>
<InputField
required
className={styles.inputField}
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.confirm_password')}
error={errors.confirmPassword && 'passwords_do_not_match'}
errorMessage={errors.confirmPassword?.message}
aria-invalid={!!errors.confirmPassword}
{...register('confirmPassword', {
validate: (value) => value === watch('newPassword'),
validate: (value) => value === watch('newPassword') || t('error.passwords_do_not_match'),
})}
isSuffixFocusVisible={!!watch('confirmPassword')}
suffix={

View file

@ -76,8 +76,6 @@ const useSocialSignInListener = (connectorId?: string) => {
const signInWithSocialHandler = useCallback(
async (connectorId: string, data: Record<string, unknown>) => {
console.log('triggered');
const [error, result] = await asyncSignInWithSocial({
connectorId,
connectorData: {

View file

@ -15,6 +15,7 @@ import IdentifierSignInForm from './index';
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
t: (key: string) => key,
}));
const mockedNavigate = jest.fn();
@ -54,7 +55,7 @@ describe('IdentifierSignInForm', () => {
});
await waitFor(() => {
expect(getByText('general_required')).not.toBeNull();
expect(getByText('error.general_required')).not.toBeNull();
});
});
@ -78,7 +79,7 @@ describe('IdentifierSignInForm', () => {
});
await waitFor(() => {
expect(getByText('general_invalid')).not.toBeNull();
expect(getByText('error.general_invalid')).not.toBeNull();
});
}
);

View file

@ -11,7 +11,7 @@ import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import { identifierErrorWatcher, validateIdentifierField } from '@/utils/form';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
import useOnSubmit from './use-on-submit';
@ -68,32 +68,25 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
);
const identifierError = identifierErrorWatcher(enabledSignInMethods, errors.identifier);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
required
autoComplete="identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}
isDanger={!!identifierError || !!errorMessage}
error={identifierError}
isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message}
enabledTypes={enabledSignInMethods}
onTypeChange={setInputType}
{...register('identifier', {
required: true,
required: getGeneralIdentifierErrorMessage(enabledSignInMethods, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
if (errorMessage) {
return typeof errorMessage === 'string'
? t(`error.${errorMessage}`)
: t(`error.${errorMessage.code}`, errorMessage.data);
}
return true;
return errorMessage
? getGeneralIdentifierErrorMessage(enabledSignInMethods, 'invalid')
: true;
},
})}
/* Overwrite default input onChange handler */

View file

@ -22,6 +22,7 @@ jest.mock('react-device-detect', () => ({
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
t: (key: string) => key,
}));
describe('UsernamePasswordSignInForm', () => {
@ -77,8 +78,8 @@ describe('UsernamePasswordSignInForm', () => {
});
await waitFor(() => {
expect(queryByText('general_required')).not.toBeNull();
expect(queryByText('password_required')).not.toBeNull();
expect(queryByText('error.general_required')).not.toBeNull();
expect(queryByText('error.password_required')).not.toBeNull();
});
const identifierInput = container.querySelector('input[name="identifier"]');
@ -96,8 +97,8 @@ describe('UsernamePasswordSignInForm', () => {
});
await waitFor(() => {
expect(queryByText('general_required')).toBeNull();
expect(queryByText('password_required')).toBeNull();
expect(queryByText('error.general_required')).toBeNull();
expect(queryByText('error.password_required')).toBeNull();
});
});
@ -122,7 +123,7 @@ describe('UsernamePasswordSignInForm', () => {
});
await waitFor(() => {
expect(queryByText('general_invalid')).not.toBeNull();
expect(queryByText('error.general_invalid')).not.toBeNull();
});
act(() => {
@ -130,7 +131,7 @@ describe('UsernamePasswordSignInForm', () => {
});
await waitFor(() => {
expect(queryByText('general_invalid')).toBeNull();
expect(queryByText('error.general_invalid')).toBeNull();
});
});

View file

@ -13,11 +13,7 @@ import TermsOfUse from '@/containers/TermsOfUse';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import {
identifierErrorWatcher,
passwordErrorWatcher,
validateIdentifierField,
} from '@/utils/form';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
@ -76,33 +72,23 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
);
const identifierError = identifierErrorWatcher(signInMethods, errors.identifier);
const passwordError = passwordErrorWatcher(errors.password);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
required
autoComplete="identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}
isDanger={!!identifierError}
error={identifierError}
isDanger={!!errors.identifier}
errorMessage={errors.identifier?.message}
enabledTypes={signInMethods}
onTypeChange={setInputType}
{...register('identifier', {
required: true,
required: getGeneralIdentifierErrorMessage(signInMethods, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
if (errorMessage) {
return typeof errorMessage === 'string'
? t(`error.${errorMessage}`)
: t(`error.${errorMessage.code}`, errorMessage.data);
}
return true;
return errorMessage ? getGeneralIdentifierErrorMessage(signInMethods, 'invalid') : true;
},
})}
/* Overwrite default input onChange handler */
@ -112,13 +98,12 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
/>
<PasswordInputField
required
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!passwordError}
error={passwordError}
{...register('password', { required: true })}
isDanger={!!errors.password}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}

View file

@ -56,7 +56,7 @@ describe('PasswordSignInForm', () => {
await waitFor(() => {
expect(signInWithPasswordIdentifier).not.toBeCalled();
expect(queryByText('password_required')).not.toBeNull();
expect(queryByText('error.password_required')).not.toBeNull();
});
const passwordInput = container.querySelector('input[name="password"]');

View file

@ -69,14 +69,13 @@ const PasswordForm = ({
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PasswordInputField
required
autoFocus={autoFocus}
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!errors.password}
error={errors.password && 'password_required'}
{...register('password', { required: true })}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}

View file

@ -1,15 +1,12 @@
import { SignInIdentifier } from '@logto/schemas';
import i18next from 'i18next';
import type { FieldError } from 'react-hook-form';
import type { TFuncKey } from 'react-i18next';
import type { ErrorType } from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { validateUsername, validateEmail, validatePhone } from './field-validations';
// eslint-disable-next-line id-length
const t = (key: TFuncKey) => i18next.t<'translation', TFuncKey>(key);
const { t } = i18next;
export const identifierInputPlaceholderMap: { [K in IdentifierInputType]: TFuncKey } = {
[SignInIdentifier.Phone]: 'input.phone_number',
@ -23,35 +20,19 @@ export const identifierInputDescriptionMap: { [K in IdentifierInputType]: TFuncK
[SignInIdentifier.Username]: 'description.username',
};
export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined => {
switch (error?.type) {
case 'required':
return 'password_required';
case 'minLength':
return { code: 'password_min_length', data: { min: 6 } };
default:
}
};
export const identifierErrorWatcher = (
export const getGeneralIdentifierErrorMessage = (
enabledFields: IdentifierInputType[],
error?: FieldError
): ErrorType | undefined => {
const data = { types: enabledFields.map((field) => t(identifierInputDescriptionMap[field])) };
type: 'required' | 'invalid'
) => {
const data = {
types: enabledFields.map((field) =>
t<'translation', TFuncKey>(identifierInputDescriptionMap[field])
),
};
switch (error?.type) {
case 'required':
return {
code: 'general_required',
data,
};
case 'validate':
return {
code: 'general_invalid',
data,
};
default:
}
const code = type === 'required' ? 'error.general_required' : 'error.general_invalid';
return t<'translation', TFuncKey>(code, data);
};
export const validateIdentifierField = (type: IdentifierInputType, value: string) => {