0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(ui): refactor smart input field (#3140)

This commit is contained in:
simeng-li 2023-02-20 14:49:47 +08:00 committed by GitHub
parent 86ecd79b36
commit e7115d797c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 357 additions and 353 deletions

View file

@ -1,9 +1,10 @@
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, render } from '@testing-library/react';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import type { EnabledIdentifierTypes, IdentifierInputType } from '.';
import type { IdentifierInputType } from '.';
import SmartInputField from '.';
jest.mock('i18next', () => ({
@ -13,23 +14,23 @@ jest.mock('i18next', () => ({
describe('SmartInputField Component', () => {
const onChange = jest.fn();
const onTypeChange = jest.fn();
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const renderInputField = (props: {
currentType: IdentifierInputType;
enabledTypes?: EnabledIdentifierTypes;
}) => render(<SmartInputField {...props} onTypeChange={onTypeChange} onChange={onChange} />);
defaultValue?: string;
defaultType?: IdentifierInputType;
enabledTypes?: IdentifierInputType[];
}) => render(<SmartInputField {...props} onChange={onChange} />);
afterEach(() => {
jest.clearAllMocks();
});
describe('standard input field', () => {
it.each([SignInIdentifier.Username, SignInIdentifier.Email])(
test.each([SignInIdentifier.Username, SignInIdentifier.Email])(
`should render %s input field`,
(currentType) => {
const { container } = renderInputField({ currentType });
const { container } = renderInputField({ enabledTypes: [currentType] });
// Country code should not be rendered
expect(container.querySelector('select')).toBeNull();
@ -38,23 +39,20 @@ describe('SmartInputField Component', () => {
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: currentType, value: 'foo' });
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: currentType, value: 'foo@' });
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: currentType, value: '12315' });
}
}
);
it('phone', async () => {
test('phone', async () => {
const { container, queryAllByText } = renderInputField({
currentType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone],
});
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
@ -67,26 +65,30 @@ describe('SmartInputField Component', () => {
if (selector) {
fireEvent.change(selector, { target: { value: newCountryCode } });
expect(onChange).toBeCalledWith(newCountryCode);
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: '',
});
}
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${newCountryCode}12315`);
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: `${newCountryCode}12315`,
});
}
});
});
describe('username with email', () => {
const config = {
currentType: SignInIdentifier.Username,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
};
it('should not update inputType if no @ char present', () => {
test('should return username type if no @ char present', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
@ -96,12 +98,11 @@ describe('SmartInputField Component', () => {
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: 'foo' });
}
});
it('should not update inputType to phone as phone is not enabled', () => {
test('should return username type with all digits input', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
@ -111,12 +112,11 @@ describe('SmartInputField Component', () => {
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: '12315' });
}
});
it('should update inputType to email with @ char', () => {
test('should return email type with @ char', () => {
const { container } = renderInputField(config);
// Country code should not be rendered
@ -126,209 +126,137 @@ describe('SmartInputField Component', () => {
if (input) {
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Email, value: 'foo@' });
}
});
});
describe('username with phone', () => {
const config = {
currentType: SignInIdentifier.Username,
enabledTypes: [SignInIdentifier.Username, SignInIdentifier.Phone],
};
it('should not update inputType if non digit chars present', () => {
test('should return username type if non digit chars present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12345@' } });
expect(onChange).toBeCalledWith('12345@');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: '12345@' });
}
});
it('should not update inputType if less than 3 digits', () => {
test('should return username type if less than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12' } });
expect(onChange).toBeCalledWith('12');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: '12' });
}
});
it('should update inputType to phone with more than 3 digits', () => {
test('should return phone type with more than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: `${defaultCountryCallingCode}12315`,
});
}
});
});
describe('email with phone', () => {
const config = {
currentType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Phone],
};
it('should not update inputType non digit char present', () => {
test('should return email type if non digit char present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).not.toBeCalled();
fireEvent.change(input, { target: { value: '12315a' } });
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Email, value: '12315a' });
}
});
it('should not update inputType if less than 3 digits', () => {
test('should return email type if less than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12' } });
expect(onChange).toBeCalledWith('12');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Email, value: '12' });
}
});
it('should update inputType to phone with more than 3 digits', () => {
test('should update inputType to phone with more than 3 digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}12315`);
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Phone);
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: `${defaultCountryCallingCode}12315`,
});
}
});
it('should not update inputType if @ present', () => {
test('should return email type if @ present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).not.toBeCalled();
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Email, value: '12315@' });
}
});
});
describe('email with username', () => {
describe('username, email and phone', () => {
const config = {
currentType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Username],
enabledTypes: [SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone],
};
it('should not update inputType if @ present', () => {
test('should call onChange properly based on different inputs', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith('foo@');
expect(onTypeChange).not.toBeCalled();
}
});
assert(input, new Error('Input field not found'));
expect(onChange).toBeCalledWith({ type: undefined, value: '' });
it('should update inputType to username with pure digits as phone is not enabled', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: 'foo' });
if (input) {
fireEvent.change(input, { target: { value: '12315' } });
expect(onChange).toBeCalledWith('12315');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
fireEvent.change(input, { target: { value: 'foo@' } });
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Email, value: 'foo@' });
it('should update inputType to username with no @ present', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
fireEvent.change(input, { target: { value: '11' } });
expect(onChange).toBeCalledWith({ type: SignInIdentifier.Username, value: '11' });
if (input) {
fireEvent.change(input, { target: { value: 'foo' } });
expect(onChange).toBeCalledWith('foo');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
});
fireEvent.change(input, { target: { value: '110' } });
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: `${defaultCountryCallingCode}110`,
});
describe('phone with username', () => {
const config = {
currentType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Username],
};
fireEvent.change(input, { target: { value: '11' } });
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Phone,
value: `${defaultCountryCallingCode}11`,
});
it('should not update inputType if all chars are digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType if non digit char found', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123@' } });
expect(onChange).toBeCalledWith('123@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Username);
}
});
});
describe('phone with email', () => {
const config = {
currentType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone, SignInIdentifier.Email],
};
it('should not update inputType if all chars are digits', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should not update inputType if no all chars are digits and no @', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '123a' } });
expect(onChange).toBeCalledWith(`${defaultCountryCallingCode}123a`);
expect(onTypeChange).not.toBeCalled();
}
});
it('should update inputType if @ char found', () => {
const { container } = renderInputField(config);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '12315@' } });
expect(onChange).toBeCalledWith('12315@');
expect(onTypeChange).toBeCalledWith(SignInIdentifier.Email);
}
fireEvent.change(input, { target: { value: '11@' } });
expect(onChange).toBeCalledWith({
type: SignInIdentifier.Email,
value: `11@`,
});
});
});
});

View file

@ -2,7 +2,7 @@ import { SignInIdentifier } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import type { HTMLProps, Ref } from 'react';
import { useImperativeHandle, useRef, forwardRef } from 'react';
import { useEffect, useImperativeHandle, useRef, forwardRef } from 'react';
import ClearIcon from '@/assets/icons/clear-icon.svg';
import IconButton from '@/components/Button/IconButton';
@ -10,62 +10,67 @@ import IconButton from '@/components/Button/IconButton';
import InputField from '../InputField';
import AnimatedPrefix from './AnimatedPrefix';
import CountryCodeSelector from './CountryCodeSelector';
import type { EnabledIdentifierTypes, IdentifierInputType } from './use-smart-input-field';
import type { IdentifierInputType, IdentifierInputValue } from './use-smart-input-field';
import useSmartInputField from './use-smart-input-field';
import { getInputHtmlProps } from './utils';
export type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
export type { IdentifierInputType, IdentifierInputValue } from './use-smart-input-field';
type Props = Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'prefix' | 'value'> & {
className?: string;
errorMessage?: string;
isDanger?: boolean;
enabledTypes?: EnabledIdentifierTypes;
currentType?: IdentifierInputType;
onTypeChange?: (type: IdentifierInputType) => void;
enabledTypes?: IdentifierInputType[];
defaultType?: IdentifierInputType;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
onChange?: (data: IdentifierInputValue) => void;
};
const SmartInputField = (
{
defaultValue,
onChange,
currentType = SignInIdentifier.Username,
enabledTypes = [currentType],
onTypeChange,
...rest
}: Props,
{ defaultValue, defaultType, enabledTypes = [], onChange, ...rest }: Props,
ref: Ref<Nullable<HTMLInputElement>>
) => {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => innerRef.current);
const { countryCode, onCountryCodeChange, inputValue, onInputValueChange, onInputValueClear } =
useSmartInputField({
defaultValue,
onChange,
enabledTypes,
currentType,
onTypeChange,
});
const {
countryCode,
onCountryCodeChange,
inputValue,
onInputValueChange,
onInputValueClear,
identifierType,
} = useSmartInputField({
_defaultType: defaultType,
defaultValue,
enabledTypes,
});
const isPhoneEnabled = enabledTypes.includes(SignInIdentifier.Phone);
useEffect(() => {
onChange?.({
type: identifierType,
value:
identifierType === SignInIdentifier.Phone && inputValue
? `${countryCode}${inputValue}`
: inputValue,
});
}, [countryCode, identifierType, inputValue, onChange]);
return (
<InputField
{...rest}
ref={innerRef}
isSuffixFocusVisible={Boolean(inputValue)}
isPrefixVisible={isPhoneEnabled && currentType === SignInIdentifier.Phone}
{...getInputHtmlProps(currentType, enabledTypes)}
isPrefixVisible={identifierType === SignInIdentifier.Phone}
{...getInputHtmlProps(enabledTypes, identifierType)}
value={inputValue}
prefix={conditional(
isPhoneEnabled && (
<AnimatedPrefix isVisible={currentType === SignInIdentifier.Phone}>
<AnimatedPrefix isVisible={identifierType === SignInIdentifier.Phone}>
<CountryCodeSelector
value={countryCode}
onChange={(event) => {

View file

@ -11,85 +11,88 @@ export type IdentifierInputType =
| SignInIdentifier.Phone
| SignInIdentifier.Username;
export type EnabledIdentifierTypes = IdentifierInputType[];
export type IdentifierInputValue = {
type: IdentifierInputType | undefined;
value: string;
};
const digitsRegex = /^\d*$/;
type Props = {
defaultValue?: string;
onChange?: (value: string) => void;
enabledTypes: EnabledIdentifierTypes;
currentType: IdentifierInputType;
onTypeChange?: (type: IdentifierInputType) => void;
_defaultType?: IdentifierInputType;
enabledTypes: IdentifierInputType[];
};
const useSmartInputField = ({
onChange,
currentType,
enabledTypes,
onTypeChange,
defaultValue,
}: Props) => {
const { countryCode: defaultCountryCode, inputValue: defaultInputValue } = parseIdentifierValue(
currentType,
defaultValue
const useSmartInputField = ({ _defaultType, defaultValue, enabledTypes }: Props) => {
const enabledTypeSet = useMemo(() => new Set(enabledTypes), [enabledTypes]);
assert(
!_defaultType || enabledTypeSet.has(_defaultType),
new Error(
`Invalid input type. Current inputType ${
_defaultType ?? ''
} is detected but missing in enabledTypes`
)
);
// Parse default type from enabled types if default type is not provided and only one type is enabled
const defaultType = useMemo(
() => _defaultType ?? (enabledTypes.length === 1 ? enabledTypes[0] : undefined),
[_defaultType, enabledTypes]
);
// Parse default value if provided
const { countryCode: defaultCountryCode, inputValue: defaultInputValue } = useMemo(
() => parseIdentifierValue(defaultType, defaultValue),
[defaultType, defaultValue]
);
const [currentType, setCurrentType] = useState(defaultType);
const [countryCode, setCountryCode] = useState<string>(
defaultCountryCode ?? getDefaultCountryCallingCode()
);
const [inputValue, setInputValue] = useState<string>(defaultInputValue ?? '');
const enabledTypeSet = useMemo(() => new Set(enabledTypes), [enabledTypes]);
assert(
enabledTypeSet.has(currentType),
new Error(
`Invalid input type. Current inputType ${currentType} is detected but missing in enabledTypes`
)
);
const detectInputType = useCallback(
(value: string) => {
if (!value || enabledTypeSet.size === 1) {
return currentType;
// Reset InputType
if (!value && enabledTypeSet.size > 1) {
return;
}
if (enabledTypeSet.size === 1) {
return defaultType;
}
const hasAtSymbol = value.includes('@');
const isAllDigits = digitsRegex.test(value);
const isEmailDetected = enabledTypeSet.has(SignInIdentifier.Email) && hasAtSymbol;
const isPhoneDetected =
enabledTypeSet.has(SignInIdentifier.Phone) && value.length > 3 && isAllDigits;
if (isPhoneDetected) {
if (enabledTypeSet.has(SignInIdentifier.Phone) && value.length >= 3 && isAllDigits) {
return SignInIdentifier.Phone;
}
if (isEmailDetected) {
if (enabledTypeSet.has(SignInIdentifier.Email) && hasAtSymbol) {
return SignInIdentifier.Email;
}
if (
currentType === SignInIdentifier.Email &&
enabledTypeSet.has(SignInIdentifier.Username) &&
!hasAtSymbol
) {
if (currentType === SignInIdentifier.Phone && isAllDigits) {
return SignInIdentifier.Phone;
}
if (enabledTypeSet.has(SignInIdentifier.Username)) {
return SignInIdentifier.Username;
}
if (
currentType === SignInIdentifier.Phone &&
enabledTypeSet.has(SignInIdentifier.Username) &&
!isAllDigits
) {
return SignInIdentifier.Username;
if (enabledTypeSet.has(SignInIdentifier.Email)) {
return SignInIdentifier.Email;
}
return currentType;
},
[currentType, enabledTypeSet]
[defaultType, currentType, enabledTypeSet]
);
const onCountryCodeChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
@ -97,10 +100,9 @@ const useSmartInputField = ({
if (currentType === SignInIdentifier.Phone) {
const code = value.replace(/\D/g, '');
setCountryCode(code);
onChange?.(`${code}${inputValue}`);
}
},
[currentType, inputValue, onChange]
[currentType]
);
const onInputValueChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
@ -109,20 +111,15 @@ const useSmartInputField = ({
setInputValue(trimValue);
const type = detectInputType(trimValue);
if (type !== currentType) {
onTypeChange?.(type);
}
onChange?.(type === SignInIdentifier.Phone ? `${countryCode}${trimValue}` : trimValue);
setCurrentType(type);
},
[countryCode, currentType, detectInputType, onChange, onTypeChange]
[detectInputType]
);
const onInputValueClear = useCallback(() => {
setInputValue('');
onChange?.('');
}, [onChange]);
setCurrentType(enabledTypeSet.size === 1 ? defaultType : undefined);
}, [defaultType, enabledTypeSet.size]);
return {
countryCode,
@ -130,6 +127,7 @@ const useSmartInputField = ({
inputValue,
onInputValueChange,
onInputValueClear,
identifierType: currentType,
};
};

View file

@ -11,7 +11,7 @@ describe('Smart Input Field Util Methods', () => {
describe('getInputHtmlProps', () => {
it('Should return correct html props for phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Phone, [SignInIdentifier.Phone]);
const props = getInputHtmlProps([SignInIdentifier.Phone], SignInIdentifier.Phone);
expect(props.type).toBe('tel');
expect(props.pattern).toBe('[0-9]*');
expect(props.inputMode).toBe('numeric');
@ -19,29 +19,29 @@ describe('Smart Input Field Util Methods', () => {
});
it('Should return correct html props for email', () => {
const props = getInputHtmlProps(SignInIdentifier.Email, [SignInIdentifier.Email]);
const props = getInputHtmlProps([SignInIdentifier.Email], SignInIdentifier.Email);
expect(props.type).toBe('email');
expect(props.inputMode).toBe('email');
expect(props.placeholder).toBe('input.email');
});
it('Should return correct html props for username', () => {
const props = getInputHtmlProps(SignInIdentifier.Username, [SignInIdentifier.Username]);
const props = getInputHtmlProps([SignInIdentifier.Username], SignInIdentifier.Username);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.username');
});
it('Should return correct html props for username email or phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Username, enabledTypes);
const props = getInputHtmlProps(enabledTypes, SignInIdentifier.Username);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.username / input.email / input.phone_number');
});
it('Should return correct html props for email or phone', () => {
const props = getInputHtmlProps(SignInIdentifier.Email, [
SignInIdentifier.Email,
SignInIdentifier.Phone,
]);
const props = getInputHtmlProps(
[SignInIdentifier.Email, SignInIdentifier.Phone],
SignInIdentifier.Email
);
expect(props.type).toBe('text');
expect(props.placeholder).toBe('input.email / input.phone_number');
});

View file

@ -5,11 +5,11 @@ import type { TFuncKey } from 'react-i18next';
import { identifierInputPlaceholderMap } from '@/utils/form';
import type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
import type { IdentifierInputType } from './use-smart-input-field';
export const getInputHtmlProps = (
currentType: IdentifierInputType,
enabledTypes: EnabledIdentifierTypes
enabledTypes: IdentifierInputType[],
currentType?: IdentifierInputType
): Pick<HTMLProps<HTMLInputElement>, 'type' | 'pattern' | 'inputMode' | 'placeholder'> => {
if (currentType === SignInIdentifier.Phone && enabledTypes.length === 1) {
return {

View file

@ -1,7 +1,3 @@
export { default as InputField } from './InputField';
export { default as PasswordInputField } from './PasswordInputField';
export {
default as SmartInputField,
type IdentifierInputType,
type EnabledIdentifierTypes,
} from './SmartInputField';
export { default as SmartInputField } from './SmartInputField';

View file

@ -4,8 +4,8 @@ import TextLink from '@/components/TextLink';
import { UserFlow } from '@/types';
type Props = {
identifier: SignInIdentifier;
value: string;
identifier?: SignInIdentifier;
value?: string;
className?: string;
};

View file

@ -63,6 +63,7 @@ describe('<SetPassword />', () => {
// Clear error
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
fireEvent.blur(passwordInput);
}
});
@ -99,6 +100,7 @@ describe('<SetPassword />', () => {
// Clear Error
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
fireEvent.blur(confirmPasswordInput);
}
});

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -42,12 +42,18 @@ const SetPassword = ({
watch,
resetField,
handleSubmit,
formState: { errors },
formState: { errors, isValid },
} = useForm<FieldState>({
reValidateMode: 'onChange',
reValidateMode: 'onBlur',
defaultValues: { newPassword: '', confirmPassword: '' },
});
useEffect(() => {
if (!isValid) {
clearErrorMessage?.();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
(event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();

View file

@ -1,12 +1,15 @@
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import type {
IdentifierInputType,
IdentifierInputValue,
} from '@/components/InputFields/SmartInputField';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
@ -15,7 +18,7 @@ type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
defaultType: IdentifierInputType;
defaultType?: IdentifierInputType;
enabledTypes: IdentifierInputType[];
onSubmit?: (identifier: IdentifierInputType, value: string) => Promise<void> | void;
@ -24,7 +27,7 @@ type Props = {
};
type FormState = {
identifier: string;
identifier: IdentifierInputValue;
};
const IdentifierProfileForm = ({
@ -37,28 +40,39 @@ const IdentifierProfileForm = ({
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const [inputType, setInputType] = useState<IdentifierInputType>(defaultType);
const {
handleSubmit,
control,
formState: { errors },
formState: { errors, isValid },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
reValidateMode: 'onBlur',
defaultValues: {
identifier: {
type: defaultType,
value: '',
},
},
});
useEffect(() => {
if (!isValid) {
clearErrorMessage?.();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
void handleSubmit(async ({ identifier: { type, value } }) => {
if (!type) {
return;
}
await onSubmit?.(inputType, identifier);
await onSubmit?.(type, value);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit]
[clearErrorMessage, handleSubmit, onSubmit]
);
return (
@ -67,9 +81,14 @@ const IdentifierProfileForm = ({
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(enabledTypes, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
validate: (identifier) => {
const { type, value } = identifier;
if (!type || !value) {
return getGeneralIdentifierErrorMessage(enabledTypes, 'required');
}
const errorMessage = validateIdentifierField(type, value);
if (errorMessage) {
return typeof errorMessage === 'string'
@ -86,11 +105,10 @@ const IdentifierProfileForm = ({
autoFocus={autoFocus}
className={styles.inputField}
{...field}
currentType={inputType}
defaultType={defaultType}
isDanger={!!errors.identifier}
errorMessage={errors.identifier?.message}
enabledTypes={enabledTypes}
onTypeChange={setInputType}
/>
)}
/>

View file

@ -1,6 +1,5 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -24,7 +23,10 @@ type Props = {
};
type FormState = {
identifier: string;
identifier: {
type?: VerificationCodeIdentifier;
value: string;
};
};
const ForgotPasswordForm = ({
@ -35,7 +37,6 @@ const ForgotPasswordForm = ({
enabledTypes,
}: Props) => {
const { t } = useTranslation();
const [inputType, setInputType] = useState<VerificationCodeIdentifier>(defaultType);
const { errorMessage, clearErrorMessage, onSubmit } = useSendVerificationCode(
UserFlow.forgotPassword
);
@ -43,23 +44,36 @@ const ForgotPasswordForm = ({
const {
handleSubmit,
control,
formState: { errors, isSubmitted },
formState: { errors, isValid },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: defaultValue },
reValidateMode: 'onBlur',
defaultValues: {
identifier: {
type: defaultType,
value: defaultValue,
},
},
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
void handleSubmit(async ({ identifier: { type, value } }) => {
if (!type) {
return;
}
await onSubmit({ identifier: inputType, value: identifier });
await onSubmit({ identifier: type, value });
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit]
[clearErrorMessage, handleSubmit, onSubmit]
);
return (
@ -68,9 +82,14 @@ const ForgotPasswordForm = ({
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(enabledTypes, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
validate: (identifier) => {
const { type, value } = identifier;
if (!type || !value) {
return getGeneralIdentifierErrorMessage(enabledTypes, 'required');
}
const errorMessage = validateIdentifierField(type, value);
if (errorMessage) {
return typeof errorMessage === 'string'
@ -87,17 +106,11 @@ const ForgotPasswordForm = ({
autoFocus={autoFocus}
className={styles.inputField}
{...field}
defaultType={defaultType}
defaultValue={defaultValue}
currentType={inputType}
isDanger={!!errors.identifier}
errorMessage={errors.identifier?.message}
enabledTypes={enabledTypes}
onTypeChange={(type) => {
// The enabledTypes is restricted to be VerificationCodeIdentifier, need this check to make TS happy
if (type !== SignInIdentifier.Username) {
setInputType(type);
}
}}
/>
)}
/>

View file

@ -20,7 +20,11 @@ const ForgotPassword = () => {
const getDefaultIdentifierType = useCallback(
(identifier?: SignInIdentifier) => {
if (identifier === SignInIdentifier.Username || identifier === SignInIdentifier.Email) {
if (
identifier === SignInIdentifier.Username ||
identifier === SignInIdentifier.Email ||
!identifier
) {
return enabledMethodSet.has(SignInIdentifier.Email)
? SignInIdentifier.Email
: SignInIdentifier.Phone;

View file

@ -50,7 +50,7 @@ describe('<IdentifierRegisterForm />', () => {
[SignInIdentifier.Email],
[SignInIdentifier.Phone],
[SignInIdentifier.Email, SignInIdentifier.Phone],
])('username %o register form', (...signUpMethods) => {
])('%p %p register form', (...signUpMethods) => {
test('default render', () => {
const { queryByText, container } = renderForm(signUpMethods);
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
@ -94,6 +94,7 @@ describe('<IdentifierRegisterForm />', () => {
act(() => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
fireEvent.blur(usernameInput);
});
await waitFor(() => {
@ -120,6 +121,7 @@ describe('<IdentifierRegisterForm />', () => {
act(() => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
fireEvent.blur(usernameInput);
});
await waitFor(() => {
@ -179,6 +181,7 @@ describe('<IdentifierRegisterForm />', () => {
act(() => {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
fireEvent.blur(emailInput);
});
await waitFor(() => {
@ -235,6 +238,7 @@ describe('<IdentifierRegisterForm />', () => {
act(() => {
fireEvent.change(phoneInput, { target: { value: '8573333333' } });
fireEvent.blur(phoneInput);
});
await waitFor(() => {

View file

@ -1,13 +1,13 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
@ -23,39 +23,46 @@ type Props = {
};
type FormState = {
identifier: string;
identifier: IdentifierInputValue;
};
const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const [inputType, setInputType] = useState<IdentifierInputType>(
signUpMethods[0] ?? SignInIdentifier.Username
);
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const {
handleSubmit,
formState: { errors },
formState: { errors, isValid },
control,
} = useForm<FormState>({
reValidateMode: 'onChange',
reValidateMode: 'onBlur',
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier }, event) => {
void handleSubmit(async ({ identifier: { type, value } }) => {
if (!type) {
return;
}
if (!(await termsValidation())) {
return;
}
await onSubmit(inputType, identifier);
await onSubmit(type, value);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
[clearErrorMessage, handleSubmit, onSubmit, termsValidation]
);
return (
@ -64,9 +71,12 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(signUpMethods, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
validate: ({ type, value }) => {
if (!type || !value) {
return getGeneralIdentifierErrorMessage(signUpMethods, 'required');
}
const errorMessage = validateIdentifierField(type, value);
if (errorMessage) {
return typeof errorMessage === 'string'
@ -83,11 +93,9 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
autoFocus={autoFocus}
className={styles.inputField}
{...field}
currentType={inputType}
isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message}
enabledTypes={signUpMethods}
onTypeChange={setInputType}
/>
)}
/>

View file

@ -1,13 +1,12 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignIn } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
@ -23,7 +22,7 @@ type Props = {
};
type FormState = {
identifier: string;
identifier: IdentifierInputValue;
};
const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
@ -35,34 +34,37 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
[signInMethods]
);
const [inputType, setInputType] = useState<IdentifierInputType>(
enabledSignInMethods[0] ?? SignInIdentifier.Username
);
const {
handleSubmit,
control,
formState: { errors },
formState: { errors, isValid },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
reValidateMode: 'onBlur',
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
void handleSubmit(async ({ identifier: { type, value } }) => {
if (!type) {
return;
}
if (!(await termsValidation())) {
return;
}
await onSubmit(inputType, identifier);
await onSubmit(type, value);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
[clearErrorMessage, handleSubmit, onSubmit, termsValidation]
);
return (
@ -71,9 +73,12 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(enabledSignInMethods, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
validate: ({ type, value }) => {
if (!type || !value) {
return getGeneralIdentifierErrorMessage(enabledSignInMethods, 'required');
}
const errorMessage = validateIdentifierField(type, value);
return errorMessage
? getGeneralIdentifierErrorMessage(enabledSignInMethods, 'invalid')
@ -86,11 +91,9 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
autoFocus={autoFocus}
className={styles.inputField}
{...field}
currentType={inputType}
isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message}
enabledTypes={enabledSignInMethods}
onTypeChange={setInputType}
/>
)}
/>

View file

@ -90,10 +90,12 @@ describe('UsernamePasswordSignInForm', () => {
act(() => {
fireEvent.change(identifierInput, { target: { value: 'username' } });
fireEvent.blur(identifierInput);
});
act(() => {
fireEvent.change(passwordInput, { target: { value: 'password' } });
fireEvent.blur(passwordInput);
});
await waitFor(() => {
@ -128,6 +130,7 @@ describe('UsernamePasswordSignInForm', () => {
act(() => {
fireEvent.change(identifierInput, { target: { value: validInput } });
fireEvent.blur(identifierInput);
});
await waitFor(() => {

View file

@ -1,13 +1,13 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField, PasswordInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
import TermsOfUse from '@/containers/TermsOfUse';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
@ -25,7 +25,7 @@ type Props = {
};
type FormState = {
identifier: string;
identifier: IdentifierInputValue;
password: string;
};
@ -36,48 +36,60 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const [inputType, setInputType] = useState<IdentifierInputType>(
signInMethods[0] ?? SignInIdentifier.Username
);
const {
watch,
register,
handleSubmit,
control,
formState: { errors },
formState: { errors, isValid },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '', password: '' },
reValidateMode: 'onBlur',
defaultValues: {
identifier: {},
password: '',
},
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier, password }, event) => {
void handleSubmit(async ({ identifier: { type, value }, password }) => {
if (!type) {
return;
}
if (!(await termsValidation())) {
return;
}
await onSubmit({
[inputType]: identifier,
[type]: value,
password,
});
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
[clearErrorMessage, handleSubmit, onSubmit, termsValidation]
);
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Controller
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(signInMethods, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
validate: ({ type, value }) => {
if (!type || !value) {
return getGeneralIdentifierErrorMessage(signInMethods, 'required');
}
const errorMessage = validateIdentifierField(type, value);
return errorMessage ? getGeneralIdentifierErrorMessage(signInMethods, 'invalid') : true;
},
@ -88,11 +100,9 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
autoFocus={autoFocus}
className={styles.inputField}
{...field}
currentType={inputType}
isDanger={!!errors.identifier}
errorMessage={errors.identifier?.message}
enabledTypes={signInMethods}
onTypeChange={setInputType}
/>
)}
/>
@ -111,8 +121,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
identifier={inputType}
value={watch('identifier')}
identifier={watch('identifier').type}
value={watch('identifier').value}
/>
)}

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -41,12 +41,18 @@ const PasswordForm = ({
const {
register,
handleSubmit,
formState: { errors },
formState: { errors, isValid },
} = useForm<FormState>({
reValidateMode: 'onChange',
reValidateMode: 'onBlur',
defaultValues: { password: '' },
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();

View file

@ -2,7 +2,7 @@ import { SignInIdentifier } from '@logto/schemas';
import i18next from 'i18next';
import type { TFuncKey } from 'react-i18next';
import type { IdentifierInputType } from '@/components/InputFields';
import type { IdentifierInputType } from '@/components/InputFields/SmartInputField';
import { parsePhoneNumber } from './country-code';
import { validateUsername, validateEmail, validatePhone } from './field-validations';
@ -53,7 +53,7 @@ export const validateIdentifierField = (type: IdentifierInputType, value: string
}
};
export const parseIdentifierValue = (type: IdentifierInputType, value?: string) => {
export const parseIdentifierValue = (type?: IdentifierInputType, value?: string) => {
if (type === SignInIdentifier.Phone && value) {
const validPhoneNumber = parsePhoneNumber(value);