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:
parent
1636f10f30
commit
d2252cef09
7 changed files with 188 additions and 296 deletions
|
@ -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} />
|
||||
|
||||
|
|
|
@ -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: '' }));
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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 }));
|
||||
}}
|
||||
|
|
|
@ -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} />
|
||||
|
|
71
packages/ui/src/hooks/use-form.ts
Normal file
71
packages/ui/src/hooks/use-form.ts
Normal 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;
|
45
packages/ui/src/utils/field-validations.ts
Normal file
45
packages/ui/src/utils/field-validations.ts
Normal 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';
|
||||
}
|
||||
};
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue