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:
parent
86ecd79b36
commit
e7115d797c
19 changed files with 357 additions and 353 deletions
|
@ -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@`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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?.();
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue