0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

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
This commit is contained in:
simeng-li 2022-04-25 14:11:57 +08:00 committed by GitHub
parent 1636f10f30
commit d2252cef09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 188 additions and 296 deletions

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const { termsValidation } = useTerms();
const { setToast } = useContext(PageContext);
const { error, result, run: asyncRegister } = useApi(register);
const validations = useMemo<FieldValidations>(
() => ({
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: '' }));
}}
/>
<PasswordInput
@ -171,14 +95,7 @@ const CreateAccount = ({ className }: Props) => {
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)}
/>
<PasswordInput
forceHidden
@ -186,14 +103,9 @@ const CreateAccount = ({ className }: Props) => {
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)
)}
/>
<TermsOfUse className={styles.terms} />

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
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<FieldValidations>(
() => ({
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: '' }));
}}
/>

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
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<FieldValidations>(
() => ({
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 }));
}}

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const { setToast } = useContext(PageContext);
const { error, result, run: asyncSignInBasic } = useApi(signInBasic);
const { termsValidation } = useTerms();
const validations = useMemo<FieldValidations>(
() => ({
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: '' }));
}}
/>
<PasswordInput
@ -140,14 +83,7 @@ const UsernameSignin = ({ className }: Props) => {
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)}
/>
<TermsOfUse className={styles.terms} />

View file

@ -0,0 +1,71 @@
import { useState, useCallback, useEffect, useRef, FormEvent } from 'react';
import { ErrorType } from '@/components/ErrorMessage';
import { entries } from '@/utils';
const useForm = <T>(initialState: T) => {
type ErrorState = {
[key in keyof T]?: ErrorType;
};
type FieldValidations = {
[key in keyof T]?: (value: T[key]) => ErrorType | undefined;
};
const [fieldValue, setFieldValue] = useState<T>(initialState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const fieldValidationsRef = useRef<FieldValidations>({});
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(
<K extends keyof T>(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<HTMLInputElement>) => {
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;

View file

@ -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';
}
};

View file

@ -9,3 +9,11 @@ export const parseQueryParameters = (parameters: string | URLSearchParams) => {
return Object.fromEntries(searchParameters.entries());
};
type Entries<T> = Array<
{
[K in keyof T]: [K, T[K]];
}[keyof T]
>;
export const entries = <T>(object: T): Entries<T> => Object.entries(object) as Entries<T>;