From e7115d797c40d929088f07c35aa237c9b9df3091 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 20 Feb 2023 14:49:47 +0800 Subject: [PATCH] refactor(ui): refactor smart input field (#3140) --- .../SmartInputField/index.test.tsx | 220 ++++++------------ .../InputFields/SmartInputField/index.tsx | 59 ++--- .../SmartInputField/use-smart-input-field.ts | 108 +++++---- .../InputFields/SmartInputField/utils.test.ts | 16 +- .../InputFields/SmartInputField/utils.ts | 6 +- .../ui/src/components/InputFields/index.tsx | 6 +- .../containers/ForgotPasswordLink/index.tsx | 4 +- .../src/containers/SetPassword/index.test.tsx | 2 + .../ui/src/containers/SetPassword/index.tsx | 12 +- .../Continue/IdentifierProfileForm/index.tsx | 54 +++-- .../ForgotPasswordForm/index.tsx | 55 +++-- .../ui/src/pages/ForgotPassword/index.tsx | 6 +- .../IdentifierRegisterForm/index.test.tsx | 6 +- .../Register/IdentifierRegisterForm/index.tsx | 42 ++-- .../SignIn/IdentifierSignInForm/index.tsx | 43 ++-- .../SignIn/PasswordSignInForm/index.test.tsx | 3 + .../pages/SignIn/PasswordSignInForm/index.tsx | 52 +++-- .../SignInPassword/PasswordForm/index.tsx | 12 +- packages/ui/src/utils/form.ts | 4 +- 19 files changed, 357 insertions(+), 353 deletions(-) diff --git a/packages/ui/src/components/InputFields/SmartInputField/index.test.tsx b/packages/ui/src/components/InputFields/SmartInputField/index.test.tsx index e57f1304c..eaafd9e35 100644 --- a/packages/ui/src/components/InputFields/SmartInputField/index.test.tsx +++ b/packages/ui/src/components/InputFields/SmartInputField/index.test.tsx @@ -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(); + defaultValue?: string; + defaultType?: IdentifierInputType; + enabledTypes?: IdentifierInputType[]; + }) => render(); 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@`, + }); }); }); }); diff --git a/packages/ui/src/components/InputFields/SmartInputField/index.tsx b/packages/ui/src/components/InputFields/SmartInputField/index.tsx index f2c6a626c..97cfdaa45 100644 --- a/packages/ui/src/components/InputFields/SmartInputField/index.tsx +++ b/packages/ui/src/components/InputFields/SmartInputField/index.tsx @@ -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, '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> ) => { const innerRef = useRef(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 ( + { diff --git a/packages/ui/src/components/InputFields/SmartInputField/use-smart-input-field.ts b/packages/ui/src/components/InputFields/SmartInputField/use-smart-input-field.ts index 49c678acc..d0ca52169 100644 --- a/packages/ui/src/components/InputFields/SmartInputField/use-smart-input-field.ts +++ b/packages/ui/src/components/InputFields/SmartInputField/use-smart-input-field.ts @@ -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( defaultCountryCode ?? getDefaultCountryCallingCode() ); const [inputValue, setInputValue] = useState(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>( @@ -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>( @@ -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, }; }; diff --git a/packages/ui/src/components/InputFields/SmartInputField/utils.test.ts b/packages/ui/src/components/InputFields/SmartInputField/utils.test.ts index fdf2c101e..70391a360 100644 --- a/packages/ui/src/components/InputFields/SmartInputField/utils.test.ts +++ b/packages/ui/src/components/InputFields/SmartInputField/utils.test.ts @@ -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'); }); diff --git a/packages/ui/src/components/InputFields/SmartInputField/utils.ts b/packages/ui/src/components/InputFields/SmartInputField/utils.ts index 5c283bd1b..c95816df9 100644 --- a/packages/ui/src/components/InputFields/SmartInputField/utils.ts +++ b/packages/ui/src/components/InputFields/SmartInputField/utils.ts @@ -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, 'type' | 'pattern' | 'inputMode' | 'placeholder'> => { if (currentType === SignInIdentifier.Phone && enabledTypes.length === 1) { return { diff --git a/packages/ui/src/components/InputFields/index.tsx b/packages/ui/src/components/InputFields/index.tsx index 8c1ceea99..6fe5c01af 100644 --- a/packages/ui/src/components/InputFields/index.tsx +++ b/packages/ui/src/components/InputFields/index.tsx @@ -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'; diff --git a/packages/ui/src/containers/ForgotPasswordLink/index.tsx b/packages/ui/src/containers/ForgotPasswordLink/index.tsx index f275a02f7..9ff55091b 100644 --- a/packages/ui/src/containers/ForgotPasswordLink/index.tsx +++ b/packages/ui/src/containers/ForgotPasswordLink/index.tsx @@ -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; }; diff --git a/packages/ui/src/containers/SetPassword/index.test.tsx b/packages/ui/src/containers/SetPassword/index.test.tsx index 845bebb2a..7bf1171ba 100644 --- a/packages/ui/src/containers/SetPassword/index.test.tsx +++ b/packages/ui/src/containers/SetPassword/index.test.tsx @@ -63,6 +63,7 @@ describe('', () => { // Clear error if (passwordInput) { fireEvent.change(passwordInput, { target: { value: '123456' } }); + fireEvent.blur(passwordInput); } }); @@ -99,6 +100,7 @@ describe('', () => { // Clear Error if (confirmPasswordInput) { fireEvent.change(confirmPasswordInput, { target: { value: '123456' } }); + fireEvent.blur(confirmPasswordInput); } }); diff --git a/packages/ui/src/containers/SetPassword/index.tsx b/packages/ui/src/containers/SetPassword/index.tsx index a19ad43d9..9c69a7d0c 100644 --- a/packages/ui/src/containers/SetPassword/index.tsx +++ b/packages/ui/src/containers/SetPassword/index.tsx @@ -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({ - reValidateMode: 'onChange', + reValidateMode: 'onBlur', defaultValues: { newPassword: '', confirmPassword: '' }, }); + useEffect(() => { + if (!isValid) { + clearErrorMessage?.(); + } + }, [clearErrorMessage, isValid]); + const onSubmitHandler = useCallback( (event?: React.FormEvent) => { clearErrorMessage?.(); diff --git a/packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx b/packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx index bf8b83934..ac305a84d 100644 --- a/packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx +++ b/packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx @@ -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; @@ -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(defaultType); - const { handleSubmit, control, - formState: { errors }, + formState: { errors, isValid }, } = useForm({ - reValidateMode: 'onChange', - defaultValues: { identifier: '' }, + reValidateMode: 'onBlur', + defaultValues: { + identifier: { + type: defaultType, + value: '', + }, + }, }); + useEffect(() => { + if (!isValid) { + clearErrorMessage?.(); + } + }, [clearErrorMessage, isValid]); + const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { 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} /> )} /> diff --git a/packages/ui/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/ui/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx index 638ce623a..b83c2d545 100644 --- a/packages/ui/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx +++ b/packages/ui/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx @@ -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(defaultType); const { errorMessage, clearErrorMessage, onSubmit } = useSendVerificationCode( UserFlow.forgotPassword ); @@ -43,23 +44,36 @@ const ForgotPasswordForm = ({ const { handleSubmit, control, - formState: { errors, isSubmitted }, + formState: { errors, isValid }, } = useForm({ - 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) => { 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); - } - }} /> )} /> diff --git a/packages/ui/src/pages/ForgotPassword/index.tsx b/packages/ui/src/pages/ForgotPassword/index.tsx index f85541096..d12680080 100644 --- a/packages/ui/src/pages/ForgotPassword/index.tsx +++ b/packages/ui/src/pages/ForgotPassword/index.tsx @@ -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; diff --git a/packages/ui/src/pages/Register/IdentifierRegisterForm/index.test.tsx b/packages/ui/src/pages/Register/IdentifierRegisterForm/index.test.tsx index fd42c012d..c5cd68b4b 100644 --- a/packages/ui/src/pages/Register/IdentifierRegisterForm/index.test.tsx +++ b/packages/ui/src/pages/Register/IdentifierRegisterForm/index.test.tsx @@ -50,7 +50,7 @@ describe('', () => { [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('', () => { act(() => { fireEvent.change(usernameInput, { target: { value: 'username' } }); + fireEvent.blur(usernameInput); }); await waitFor(() => { @@ -120,6 +121,7 @@ describe('', () => { act(() => { fireEvent.change(usernameInput, { target: { value: 'username' } }); + fireEvent.blur(usernameInput); }); await waitFor(() => { @@ -179,6 +181,7 @@ describe('', () => { act(() => { fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); + fireEvent.blur(emailInput); }); await waitFor(() => { @@ -235,6 +238,7 @@ describe('', () => { act(() => { fireEvent.change(phoneInput, { target: { value: '8573333333' } }); + fireEvent.blur(phoneInput); }); await waitFor(() => { diff --git a/packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx index 3537cf6a9..f48a65e44 100644 --- a/packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx +++ b/packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx @@ -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( - signUpMethods[0] ?? SignInIdentifier.Username - ); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); const { handleSubmit, - formState: { errors }, + formState: { errors, isValid }, control, } = useForm({ - reValidateMode: 'onChange', + reValidateMode: 'onBlur', }); + useEffect(() => { + if (!isValid) { + clearErrorMessage(); + } + }, [clearErrorMessage, isValid]); + const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { 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} /> )} /> diff --git a/packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx b/packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx index fe8973e04..498883684 100644 --- a/packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx +++ b/packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx @@ -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( - enabledSignInMethods[0] ?? SignInIdentifier.Username - ); - const { handleSubmit, control, - formState: { errors }, + formState: { errors, isValid }, } = useForm({ - reValidateMode: 'onChange', - defaultValues: { identifier: '' }, + reValidateMode: 'onBlur', }); + useEffect(() => { + if (!isValid) { + clearErrorMessage(); + } + }, [clearErrorMessage, isValid]); + const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { 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} /> )} /> diff --git a/packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx b/packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx index ec30c803e..eee0136d3 100644 --- a/packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx +++ b/packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx @@ -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(() => { diff --git a/packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx b/packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx index 74c007e0a..4a4b37929 100644 --- a/packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx +++ b/packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx @@ -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( - signInMethods[0] ?? SignInIdentifier.Username - ); - const { watch, register, handleSubmit, control, - formState: { errors }, + formState: { errors, isValid }, } = useForm({ - reValidateMode: 'onChange', - defaultValues: { identifier: '', password: '' }, + reValidateMode: 'onBlur', + defaultValues: { + identifier: {}, + password: '', + }, }); const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { 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 (
{ - 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 && ( )} diff --git a/packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx b/packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx index d03d71e06..71c460321 100644 --- a/packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx +++ b/packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx @@ -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({ - reValidateMode: 'onChange', + reValidateMode: 'onBlur', defaultValues: { password: '' }, }); + useEffect(() => { + if (!isValid) { + clearErrorMessage(); + } + }, [clearErrorMessage, isValid]); + const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { clearErrorMessage(); diff --git a/packages/ui/src/utils/form.ts b/packages/ui/src/utils/form.ts index 5cacd4598..ebac8e852 100644 --- a/packages/ui/src/utils/form.ts +++ b/packages/ui/src/utils/form.ts @@ -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);