From d2252cef090db16d1ed838109e6424eb4afd8363 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 25 Apr 2022 14:11:57 +0800 Subject: [PATCH] refactor(ui): refactor ui form (#651) * refactor(ui): refactor ui form create useForm hook * fix(ui): cr fix cr fix * fix(ui): cr fix cr fix --- .../ui/src/containers/CreateAccount/index.tsx | 134 +++--------------- .../Passwordless/EmailPasswordless.tsx | 71 ++-------- .../Passwordless/PhonePasswordless.tsx | 69 +++------ .../src/containers/UsernameSignin/index.tsx | 86 ++--------- packages/ui/src/hooks/use-form.ts | 71 ++++++++++ packages/ui/src/utils/field-validations.ts | 45 ++++++ packages/ui/src/utils/index.ts | 8 ++ 7 files changed, 188 insertions(+), 296 deletions(-) create mode 100644 packages/ui/src/hooks/use-form.ts create mode 100644 packages/ui/src/utils/field-validations.ts diff --git a/packages/ui/src/containers/CreateAccount/index.tsx b/packages/ui/src/containers/CreateAccount/index.tsx index 94c4d243f..2eaa565e5 100644 --- a/packages/ui/src/containers/CreateAccount/index.tsx +++ b/packages/ui/src/containers/CreateAccount/index.tsx @@ -1,22 +1,26 @@ /** * TODO: * 1. API redesign handle api error and loading status globally in PageContext - * 2. Input field validation, should move the validation rule to the input field scope */ import classNames from 'classnames'; -import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; +import React, { useEffect, useCallback, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { register } from '@/apis/register'; import Button from '@/components/Button'; -import { ErrorType } from '@/components/ErrorMessage'; import Input from '@/components/Input'; import PasswordInput from '@/components/Input/PasswordInput'; import TermsOfUse from '@/containers/TermsOfUse'; import useApi from '@/hooks/use-api'; +import useForm from '@/hooks/use-form'; import { PageContext } from '@/hooks/use-page-context'; import useTerms from '@/hooks/use-terms'; +import { + usernameValidation, + passwordValidation, + confirmPasswordValidation, +} from '@/utils/field-validations'; import * as styles from './index.module.scss'; @@ -26,89 +30,30 @@ type FieldState = { confirmPassword: string; }; -type ErrorState = { - [key in keyof FieldState]?: ErrorType; -}; - -type FieldValidations = { - [key in keyof FieldState]?: (state: FieldState) => ErrorType | undefined; -}; - type Props = { className?: string; }; -const defaultState = { +const defaultState: FieldState = { username: '', password: '', confirmPassword: '', }; -const usernameRegx = /^[A-Z_a-z-][\w-]*$/; - const CreateAccount = ({ className }: Props) => { const { t, i18n } = useTranslation(undefined, { keyPrefix: 'main_flow' }); - const [fieldState, setFieldState] = useState(defaultState); - const [fieldErrors, setFieldErrors] = useState({}); const { termsValidation } = useTerms(); - const { setToast } = useContext(PageContext); - const { error, result, run: asyncRegister } = useApi(register); - - const validations = useMemo( - () => ({ - username: ({ username }) => { - if (!username) { - return { code: 'required', data: { field: t('input.username') } }; - } - - if (/\d/.test(username.slice(0, 1))) { - return 'username_should_not_start_with_number'; - } - - if (!usernameRegx.test(username)) { - return 'username_valid_charset'; - } - }, - password: ({ password }) => { - if (!password) { - return { code: 'required', data: { field: t('input.password') } }; - } - - if (password.length < 6) { - return { code: 'password_min_length', data: { min: 6 } }; - } - }, - confirmPassword: ({ password, confirmPassword }) => { - if (password !== confirmPassword) { - return { code: 'passwords_do_not_match' }; - } - }, - }), - [t] - ); + const { + fieldValue, + setFieldValue, + register: fieldRegister, + validateForm, + } = useForm(defaultState); const onSubmitHandler = useCallback(() => { - // Validates - const usernameError = validations.username?.(fieldState); - const passwordError = validations.password?.(fieldState); - - if (usernameError || passwordError) { - setFieldErrors((previous) => ({ - ...previous, - username: usernameError, - password: passwordError, - })); - - return; - } - - const confirmPasswordError = validations.confirmPassword?.(fieldState); - - if (confirmPasswordError) { - setFieldErrors((previous) => ({ ...previous, confirmPassword: confirmPasswordError })); - + if (!validateForm()) { return; } @@ -116,8 +61,8 @@ const CreateAccount = ({ className }: Props) => { return; } - void asyncRegister(fieldState.username, fieldState.password); - }, [validations, fieldState, termsValidation, asyncRegister]); + void asyncRegister(fieldValue.username, fieldValue.password); + }, [validateForm, termsValidation, asyncRegister, fieldValue]); useEffect(() => { if (result?.redirectTo) { @@ -125,20 +70,6 @@ const CreateAccount = ({ className }: Props) => { } }, [result]); - useEffect(() => { - // Clear errors - for (const key of Object.keys(fieldState) as [keyof FieldState]) { - setFieldErrors((previous) => { - if (!previous[key]) { - return previous; - } - const error = validations[key]?.(fieldState); - - return { ...previous, [key]: error }; - }); - } - }, [fieldState, validations]); - useEffect(() => { // TODO: username exist error message if (error) { @@ -153,16 +84,9 @@ const CreateAccount = ({ className }: Props) => { name="username" autoComplete="username" placeholder={t('input.username')} - value={fieldState.username} - error={fieldErrors.username} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, username: value })); - } - }} + {...fieldRegister('username', usernameValidation)} onClear={() => { - setFieldState((state) => ({ ...state, username: '' })); + setFieldValue((state) => ({ ...state, username: '' })); }} /> { name="password" autoComplete="current-password" placeholder={t('input.password')} - value={fieldState.password} - error={fieldErrors.password} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, password: value })); - } - }} + {...fieldRegister('password', passwordValidation)} /> { name="confirm_password" autoComplete="current-password" placeholder={t('input.confirm_password')} - value={fieldState.confirmPassword} - error={fieldErrors.confirmPassword} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, confirmPassword: value })); - } - }} + {...fieldRegister('confirmPassword', (confirmPassword) => + confirmPasswordValidation(fieldValue.password, confirmPassword) + )} /> diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index cada21b98..8f432eab2 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -1,22 +1,22 @@ /** * TODO: * 1. API redesign handle api error and loading status globally in PageContext - * 2. Input field validation, should move the validation rule to the input field scope */ import classNames from 'classnames'; -import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'; +import React, { useCallback, useEffect, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { getSendPasscodeApi } from '@/apis/utils'; import Button from '@/components/Button'; -import { ErrorType } from '@/components/ErrorMessage'; import Input from '@/components/Input'; import TermsOfUse from '@/containers/TermsOfUse'; import useApi from '@/hooks/use-api'; +import useForm from '@/hooks/use-form'; import { PageContext } from '@/hooks/use-page-context'; import useTerms from '@/hooks/use-terms'; import { UserFlow } from '@/types'; +import { emailValidation } from '@/utils/field-validations'; import * as styles from './index.module.scss'; @@ -29,47 +29,20 @@ type FieldState = { email: string; }; -type ErrorState = { - [key in keyof FieldState]?: ErrorType; -}; - -type FieldValidations = { - [key in keyof FieldState]: (state: FieldState) => ErrorType | undefined; -}; - const defaultState: FieldState = { email: '' }; -const emailRegEx = /^\S+@\S+\.\S+$/; - const EmailPasswordless = ({ type, className }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); - const [fieldState, setFieldState] = useState(defaultState); - const [fieldErrors, setFieldErrors] = useState({}); const { setToast } = useContext(PageContext); const navigate = useNavigate(); const { termsValidation } = useTerms(); + const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); const sendPasscode = getSendPasscodeApi(type, 'email'); - const { error, result, run: asyncSendPasscode } = useApi(sendPasscode); - const validations = useMemo( - () => ({ - email: ({ email }) => { - if (!emailRegEx.test(email)) { - return 'invalid_email'; - } - }, - }), - [] - ); - const onSubmitHandler = useCallback(() => { - const emailError = validations.email(fieldState); - - if (emailError) { - setFieldErrors((previous) => ({ ...previous, email: emailError })); - + if (!validateForm()) { return; } @@ -77,29 +50,14 @@ const EmailPasswordless = ({ type, className }: Props) => { return; } - void asyncSendPasscode(fieldState.email); - }, [validations, fieldState, termsValidation, asyncSendPasscode]); + void asyncSendPasscode(fieldValue.email); + }, [validateForm, termsValidation, asyncSendPasscode, fieldValue.email]); useEffect(() => { if (result) { - navigate(`/${type}/email/passcode-validation`, { state: { email: fieldState.email } }); + navigate(`/${type}/email/passcode-validation`, { state: { email: fieldValue.email } }); } - }, [fieldState.email, navigate, result, type]); - - useEffect(() => { - // Clear errors - for (const key of Object.keys(fieldState) as [keyof FieldState]) { - if (fieldState[key]) { - setFieldErrors((previous) => { - if (!previous[key]) { - return previous; - } - - return { ...previous, [key]: validations[key](fieldState) }; - }); - } - } - }, [fieldState, validations]); + }, [fieldValue.email, navigate, result, type]); useEffect(() => { // TODO: request error @@ -115,16 +73,9 @@ const EmailPasswordless = ({ type, className }: Props) => { name="email" autoComplete="email" placeholder={t('input.email')} - value={fieldState.email} - error={fieldErrors.email} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, email: value })); - } - }} + {...register('email', emailValidation)} onClear={() => { - setFieldState((state) => ({ ...state, email: '' })); + setFieldValue((state) => ({ ...state, email: '' })); }} /> diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx index f8b9a37b0..6356d70ae 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -1,19 +1,18 @@ /** * TODO: * 1. API redesign handle api error and loading status globally in PageContext - * 2. Input field validation, should move the validation rule to the input field scope */ import classNames from 'classnames'; -import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'; +import React, { useCallback, useEffect, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { getSendPasscodeApi } from '@/apis/utils'; import Button from '@/components/Button'; -import { ErrorType } from '@/components/ErrorMessage'; import PhoneInput from '@/components/Input/PhoneInput'; import TermsOfUse from '@/containers/TermsOfUse'; import useApi from '@/hooks/use-api'; +import useForm from '@/hooks/use-form'; import { PageContext } from '@/hooks/use-page-context'; import usePhoneNumber, { countryList } from '@/hooks/use-phone-number'; import useTerms from '@/hooks/use-terms'; @@ -30,46 +29,30 @@ type FieldState = { phone: string; }; -type ErrorState = { - [key in keyof FieldState]?: ErrorType; -}; - -type FieldValidations = { - [key in keyof FieldState]: (state: FieldState) => ErrorType | undefined; -}; - const defaultState: FieldState = { phone: '' }; const PhonePasswordless = ({ type, className }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); - const [fieldState, setFieldState] = useState(defaultState); - const [fieldErrors, setFieldErrors] = useState({}); + const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); + const { fieldValue, setFieldValue, validateForm, register } = useForm(defaultState); const { setToast } = useContext(PageContext); const navigate = useNavigate(); const { termsValidation } = useTerms(); - const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); - const sendPasscode = getSendPasscodeApi(type, 'sms'); const { error, result, run: asyncSendPasscode } = useApi(sendPasscode); - const validations = useMemo( - () => ({ - phone: ({ phone }) => { - if (!isValidPhoneNumber(phone)) { - return 'invalid_phone'; - } - }, - }), + const phoneNumberValidation = useCallback( + (phoneNumber: string) => { + if (!isValidPhoneNumber(phoneNumber)) { + return 'invalid_phone'; + } + }, [isValidPhoneNumber] ); const onSubmitHandler = useCallback(() => { - const phoneError = validations.phone(fieldState); - - if (phoneError) { - setFieldErrors((previous) => ({ ...previous, phone: phoneError })); - + if (!validateForm()) { return; } @@ -77,36 +60,22 @@ const PhonePasswordless = ({ type, className }: Props) => { return; } - void asyncSendPasscode(fieldState.phone); - }, [validations, fieldState, termsValidation, asyncSendPasscode]); + void asyncSendPasscode(fieldValue.phone); + }, [validateForm, termsValidation, asyncSendPasscode, fieldValue.phone]); useEffect(() => { - setFieldState((previous) => ({ + // Sync phoneNumber + setFieldValue((previous) => ({ ...previous, phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`, })); - }, [phoneNumber]); + }, [phoneNumber, setFieldValue]); useEffect(() => { if (result) { - navigate(`/${type}/sms/passcode-validation`, { state: { phone: fieldState.phone } }); + navigate(`/${type}/sms/passcode-validation`, { state: { phone: fieldValue.phone } }); } - }, [fieldState.phone, navigate, result, type]); - - useEffect(() => { - // Clear errors - for (const key of Object.keys(fieldState) as [keyof FieldState]) { - if (fieldState[key]) { - setFieldErrors((previous) => { - if (!previous[key]) { - return previous; - } - - return { ...previous, [key]: validations[key](fieldState) }; - }); - } - } - }, [fieldState, validations]); + }, [fieldValue.phone, navigate, result, type]); useEffect(() => { if (error) { @@ -124,7 +93,7 @@ const PhonePasswordless = ({ type, className }: Props) => { countryCallingCode={phoneNumber.countryCallingCode} nationalNumber={phoneNumber.nationalNumber} countryList={countryList} - error={fieldErrors.phone} + {...register('phone', phoneNumberValidation)} onChange={(data) => { setPhoneNumber((previous) => ({ ...previous, ...data })); }} diff --git a/packages/ui/src/containers/UsernameSignin/index.tsx b/packages/ui/src/containers/UsernameSignin/index.tsx index d27e9cfad..5d6825324 100644 --- a/packages/ui/src/containers/UsernameSignin/index.tsx +++ b/packages/ui/src/containers/UsernameSignin/index.tsx @@ -1,22 +1,22 @@ /** * TODO: * 1. API redesign handle api error and loading status globally in PageContext - * 2. Input field validation, should move the validation rule to the input field scope */ import classNames from 'classnames'; -import React, { useState, useCallback, useEffect, useContext, useMemo } from 'react'; +import React, { useCallback, useEffect, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { signInBasic } from '@/apis/sign-in'; import Button from '@/components/Button'; -import { ErrorType } from '@/components/ErrorMessage'; import Input from '@/components/Input'; import PasswordInput from '@/components/Input/PasswordInput'; import TermsOfUse from '@/containers/TermsOfUse'; import useApi from '@/hooks/use-api'; +import useForm from '@/hooks/use-form'; import { PageContext } from '@/hooks/use-page-context'; import useTerms from '@/hooks/use-terms'; +import { usernameValidation, passwordValidation } from '@/utils/field-validations'; import * as styles from './index.module.scss'; @@ -25,14 +25,6 @@ type FieldState = { password: string; }; -type ErrorState = { - [key in keyof FieldState]?: ErrorType; -}; - -type FieldValidations = { - [key in keyof FieldState]?: (state: FieldState) => ErrorType | undefined; -}; - type Props = { className?: string; }; @@ -44,40 +36,13 @@ const defaultState: FieldState = { const UsernameSignin = ({ className }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); - const [fieldState, setFieldState] = useState(defaultState); - const [fieldErrors, setFieldErrors] = useState({}); const { setToast } = useContext(PageContext); const { error, result, run: asyncSignInBasic } = useApi(signInBasic); const { termsValidation } = useTerms(); - - const validations = useMemo( - () => ({ - username: ({ username }) => { - if (!username) { - return { code: 'required', data: { field: t('input.username') } }; - } - }, - password: ({ password }) => { - if (!password) { - return { code: 'required', data: { field: t('input.password') } }; - } - }, - }), - [t] - ); + const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); const onSubmitHandler = useCallback(async () => { - // Validates - const usernameError = validations.username?.(fieldState); - const passwordError = validations.password?.(fieldState); - - if (usernameError || passwordError) { - setFieldErrors((previous) => ({ - ...previous, - username: usernameError, - password: passwordError, - })); - + if (!validateForm()) { return; } @@ -85,8 +50,8 @@ const UsernameSignin = ({ className }: Props) => { return; } - void asyncSignInBasic(fieldState.username, fieldState.password); - }, [validations, fieldState, asyncSignInBasic, termsValidation]); + void asyncSignInBasic(fieldValue.username, fieldValue.password); + }, [validateForm, termsValidation, asyncSignInBasic, fieldValue]); useEffect(() => { if (result?.redirectTo) { @@ -95,22 +60,7 @@ const UsernameSignin = ({ className }: Props) => { }, [result]); useEffect(() => { - // Clear errors - for (const key of Object.keys(fieldState) as [keyof FieldState]) { - if (fieldState[key]) { - setFieldErrors((previous) => { - if (!previous[key]) { - return previous; - } - - return { ...previous, [key]: undefined }; - }); - } - } - }, [fieldState]); - - useEffect(() => { - // TODO: username password not correct error message + // TODO: API error message if (error) { setToast(t('error.request', { ...error })); } @@ -123,16 +73,9 @@ const UsernameSignin = ({ className }: Props) => { name="username" autoComplete="username" placeholder={t('input.username')} - value={fieldState.username} - error={fieldErrors.username} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, username: value })); - } - }} + {...register('username', usernameValidation)} onClear={() => { - setFieldState((state) => ({ ...state, username: '' })); + setFieldValue((state) => ({ ...state, username: '' })); }} /> { name="password" autoComplete="current-password" placeholder={t('input.password')} - value={fieldState.password} - error={fieldErrors.password} - onChange={({ target }) => { - if (target instanceof HTMLInputElement) { - const { value } = target; - setFieldState((state) => ({ ...state, password: value })); - } - }} + {...register('password', passwordValidation)} /> diff --git a/packages/ui/src/hooks/use-form.ts b/packages/ui/src/hooks/use-form.ts new file mode 100644 index 000000000..2ddb0361e --- /dev/null +++ b/packages/ui/src/hooks/use-form.ts @@ -0,0 +1,71 @@ +import { useState, useCallback, useEffect, useRef, FormEvent } from 'react'; + +import { ErrorType } from '@/components/ErrorMessage'; +import { entries } from '@/utils'; + +const useForm = (initialState: T) => { + type ErrorState = { + [key in keyof T]?: ErrorType; + }; + + type FieldValidations = { + [key in keyof T]?: (value: T[key]) => ErrorType | undefined; + }; + + const [fieldValue, setFieldValue] = useState(initialState); + const [fieldErrors, setFieldErrors] = useState({}); + + const fieldValidationsRef = useRef({}); + + const validateForm = useCallback(() => { + const errors = entries(fieldValue).map<[keyof T, ErrorType | undefined]>(([key, value]) => [ + key, + fieldValidationsRef.current[key]?.(value), + ]); + + setFieldErrors(Object.fromEntries(errors) as ErrorState); + + return errors.every(([, error]) => error === undefined); + }, [fieldValidationsRef, fieldValue]); + + const register = useCallback( + (field: K, validation: (value: T[K]) => ErrorType | undefined) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + fieldValidationsRef.current[field] = validation; + + return { + value: fieldValue[field], + error: fieldErrors[field], + onChange: ({ target }: FormEvent) => { + const { value } = target as HTMLInputElement; + setFieldValue((previous) => ({ ...previous, [field]: value })); + }, + }; + }, + [fieldErrors, fieldValue] + ); + + // Revalidate on Input change + useEffect(() => { + setFieldErrors((previous) => { + const errors = entries(fieldValue).map<[keyof T, ErrorType | undefined]>(([key, value]) => [ + key, + // Only validate field with existing errors + previous[key] && fieldValidationsRef.current[key]?.(value), + ]); + + return Object.fromEntries(errors) as ErrorState; + }); + }, [fieldValue, fieldValidationsRef]); + + return { + fieldValue, + fieldErrors, + validateForm, + setFieldValue, + setFieldErrors, + register, + }; +}; + +export default useForm; diff --git a/packages/ui/src/utils/field-validations.ts b/packages/ui/src/utils/field-validations.ts new file mode 100644 index 000000000..be1f57270 --- /dev/null +++ b/packages/ui/src/utils/field-validations.ts @@ -0,0 +1,45 @@ +import i18next from 'i18next'; + +import { ErrorType } from '@/components/ErrorMessage'; + +const usernameRegex = /^[A-Z_a-z-][\w-]*$/; +const emailRegex = /^\S+@\S+\.\S+$/; + +export const usernameValidation = (username: string): ErrorType | undefined => { + if (!username) { + return { code: 'required', data: { field: i18next.t('input.username') } }; + } + + if (/^\d/.test(username)) { + return 'username_should_not_start_with_number'; + } + + if (!usernameRegex.test(username)) { + return 'username_valid_charset'; + } +}; + +export const passwordValidation = (password: string): ErrorType | undefined => { + if (!password) { + return { code: 'required', data: { field: i18next.t('input.password') } }; + } + + if (password.length < 6) { + return { code: 'password_min_length', data: { min: 6 } }; + } +}; + +export const confirmPasswordValidation = ( + password: string, + confirmPassword: string +): ErrorType | undefined => { + if (password !== confirmPassword) { + return { code: 'passwords_do_not_match' }; + } +}; + +export const emailValidation = (email: string): ErrorType | undefined => { + if (!emailRegex.test(email)) { + return 'invalid_email'; + } +}; diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 1a55d0b52..5f9c66f8a 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -9,3 +9,11 @@ export const parseQueryParameters = (parameters: string | URLSearchParams) => { return Object.fromEntries(searchParameters.entries()); }; + +type Entries = Array< + { + [K in keyof T]: [K, T[K]]; + }[keyof T] +>; + +export const entries = (object: T): Entries => Object.entries(object) as Entries;