F: Rework signup form using react-hook-form and zod
Some checks are pending
Frontend CI / build (22.x) (push) Waiting to run

This commit is contained in:
Ivan 2025-02-03 13:14:39 +03:00
parent 27234c488d
commit 6583f44209
7 changed files with 84 additions and 74 deletions

View File

@ -4,14 +4,14 @@ import { z } from 'zod';
import { axiosGet, axiosPost } from '@/backend/apiTransport'; import { axiosGet, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { ICurrentUser } from '@/models/user'; import { ICurrentUser } from '@/models/user';
import { information } from '@/utils/labels'; import { errors, information } from '@/utils/labels';
/** /**
* Represents login data, used to authenticate users. * Represents login data, used to authenticate users.
*/ */
export const UserLoginSchema = z.object({ export const UserLoginSchema = z.object({
username: z.string().nonempty('Поле логина обязательно для заполнения'), username: z.string().nonempty(errors.requiredField),
password: z.string().nonempty('Поле пароля обязательно для заполнения') password: z.string().nonempty(errors.requiredField)
}); });
/** /**

View File

@ -1,22 +1,41 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { IUser, IUserInfo, IUserProfile } from '@/models/user'; import { IUserInfo, IUserProfile } from '@/models/user';
import { information } from '@/utils/labels'; import { patterns } from '@/utils/constants';
import { errors, information } from '@/utils/labels';
/** /**
* Represents signup data, used to create new users. * Represents signup data, used to create new users.
*/ */
export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> { export const UserSignupSchema = z
password: string; .object({
password2: string; username: z.string().nonempty(errors.requiredField).regex(RegExp(patterns.login), errors.loginFormat),
} email: z.string().email(errors.emailField),
first_name: z.string(),
last_name: z.string(),
password: z.string().nonempty(errors.requiredField),
password2: z.string().nonempty(errors.requiredField)
})
.refine(schema => schema.password === schema.password2, { path: ['password2'], message: errors.passwordsMismatch });
/**
* Represents signup data, used to create new users.
*/
export type IUserSignupDTO = z.infer<typeof UserSignupSchema>;
/** /**
* Represents user data, intended to update user profile in persistent storage. * Represents user data, intended to update user profile in persistent storage.
*/ */
export interface IUpdateProfileDTO extends Omit<IUser, 'is_staff' | 'id'> {} export interface IUpdateProfileDTO {
username: string;
email: string;
first_name: string;
last_name: string;
}
export const usersApi = { export const usersApi = {
baseKey: 'users', baseKey: 'users',
@ -41,8 +60,8 @@ export const usersApi = {
}) })
}), }),
signup: (data: IUserSignupData) => signup: (data: IUserSignupDTO) =>
axiosPost<IUserSignupData, IUserProfile>({ axiosPost<IUserSignupDTO, IUserProfile>({
endpoint: '/users/api/signup', endpoint: '/users/api/signup',
request: { request: {
data: data, data: data,

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { IUserSignupData, usersApi } from '@/backend/users/api'; import { IUserSignupDTO, usersApi } from '@/backend/users/api';
import { IUserProfile } from '@/models/user'; import { IUserProfile } from '@/models/user';
export const useSignup = () => { export const useSignup = () => {
@ -13,7 +13,7 @@ export const useSignup = () => {
}); });
return { return {
signup: ( signup: (
data: IUserSignupData, // data: IUserSignupDTO, //
onSuccess?: DataCallback<IUserProfile> onSuccess?: DataCallback<IUserProfile>
) => mutation.mutate(data, { onSuccess }), ) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,

View File

@ -12,7 +12,7 @@ export interface CheckboxProps extends Omit<CProps.Button, 'value' | 'onClick'>
disabled?: boolean; disabled?: boolean;
/** Current value - `true` or `false`. */ /** Current value - `true` or `false`. */
value: boolean; value?: boolean;
/** Callback to set the `value`. */ /** Callback to set the `value`. */
setValue?: (newValue: boolean) => void; setValue?: (newValue: boolean) => void;

View File

@ -77,7 +77,7 @@ function LoginPage() {
id='username' id='username'
autoComplete='username' autoComplete='username'
label='Логин или email' label='Логин или email'
{...register('username', { required: true })} {...register('username')}
autoFocus autoFocus
allowEnter allowEnter
spellCheck={false} spellCheck={false}
@ -86,7 +86,7 @@ function LoginPage() {
/> />
<TextInput <TextInput
id='password' id='password'
{...register('password', { required: true })} {...register('password')}
type='password' type='password'
autoComplete='current-password' autoComplete='current-password'
label='Пароль' label='Пароль'

View File

@ -1,11 +1,14 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios'; import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IUserSignupDTO, UserSignupSchema } from '@/backend/users/api';
import { useSignup } from '@/backend/users/useSignup'; import { useSignup } from '@/backend/users/useSignup';
import { IconHelp } from '@/components/Icons'; import { IconHelp } from '@/components/Icons';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
@ -23,23 +26,25 @@ import { globals, patterns } from '@/utils/constants';
function FormSignup() { function FormSignup() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { signup, isPending, error, reset } = useSignup(); const { signup, isPending, error: serverError, reset } = useSignup();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [password2, setPassword2] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [acceptPrivacy, setAcceptPrivacy] = useState(false); const [acceptPrivacy, setAcceptPrivacy] = useState(false);
const [acceptRules, setAcceptRules] = useState(false); const [acceptRules, setAcceptRules] = useState(false);
// const isValid = acceptPrivacy && acceptRules && !!email && !!username; const {
register,
handleSubmit,
clearErrors,
formState: { errors }
} = useForm<IUserSignupDTO>({
resolver: zodResolver(UserSignupSchema)
});
useEffect(() => { const isValid = acceptPrivacy && acceptRules;
function resetErrors() {
reset(); reset();
}, [username, email, password, password2, reset]); clearErrors();
}
function handleCancel() { function handleCancel() {
if (router.canBack()) { if (router.canBack()) {
@ -49,25 +54,15 @@ function FormSignup() {
} }
} }
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function onSubmit(data: IUserSignupDTO) {
event.preventDefault(); signup(data, createdUser => router.push(urls.login_hint(createdUser.username)));
if (isPending) {
return;
}
signup(
{
username,
email,
password,
password2,
first_name: firstName,
last_name: lastName
},
createdUser => router.push(urls.login_hint(createdUser.username))
);
} }
return ( return (
<form className={clsx('cc-fade-in cc-column', 'mx-auto w-[36rem]', 'px-6 py-3')} onSubmit={handleSubmit}> <form
className={clsx('cc-fade-in cc-column', 'mx-auto w-[36rem]', 'px-6 py-3')}
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>
<h1> <h1>
<span>Новый пользователь</span> <span>Новый пользователь</span>
<Overlay id={globals.password_tooltip} position='top-[5.4rem] left-[3.5rem]'> <Overlay id={globals.password_tooltip} position='top-[5.4rem] left-[3.5rem]'>
@ -87,68 +82,59 @@ function FormSignup() {
<FlexColumn> <FlexColumn>
<TextInput <TextInput
id='username' id='username'
name='username' {...register('username')}
autoComplete='username' autoComplete='username'
required
label='Имя пользователя (логин)' label='Имя пользователя (логин)'
spellCheck={false} spellCheck={false}
pattern={patterns.login} pattern={patterns.login}
title='Минимум 3 знака. Латинские буквы и цифры. Не может начинаться с цифры' title='Минимум 3 знака. Латинские буквы и цифры. Не может начинаться с цифры'
value={username}
className='w-[15rem]' className='w-[15rem]'
onChange={event => setUsername(event.target.value)} error={errors.username}
/> />
<TextInput <TextInput
id='password' id='password'
type='password' type='password'
name='password' {...register('password')}
autoComplete='new-password' autoComplete='new-password'
required
label='Пароль' label='Пароль'
className='w-[15rem]' className='w-[15rem]'
value={password} error={errors.password}
onChange={event => setPassword(event.target.value)}
/> />
<TextInput <TextInput
id='password2' id='password2'
type='password' type='password'
name='password2' {...register('password2')}
label='Повторите пароль' label='Повторите пароль'
autoComplete='new-password' autoComplete='new-password'
required
className='w-[15rem]' className='w-[15rem]'
value={password2} error={errors.password2}
onChange={event => setPassword2(event.target.value)}
/> />
</FlexColumn> </FlexColumn>
<FlexColumn className='w-[15rem]'> <FlexColumn className='w-[15rem]'>
<TextInput <TextInput
id='email' id='email'
name='email' {...register('email')}
autoComplete='email' autoComplete='email'
required required
spellCheck={false} spellCheck={false}
label='Электронная почта (email)' label='Электронная почта (email)'
title='электронная почта в корректном формате, например: i.petrov@mycompany.ru.com' title='электронная почта в корректном формате, например: i.petrov@mycompany.ru.com'
value={email} error={errors.email}
onChange={event => setEmail(event.target.value)}
/> />
<TextInput <TextInput
id='first_name' id='first_name'
name='first_name' {...register('first_name')}
label='Отображаемое имя' label='Отображаемое имя'
autoComplete='given-name' autoComplete='given-name'
value={firstName} error={errors.first_name}
onChange={event => setFirstName(event.target.value)}
/> />
<TextInput <TextInput
id='last_name' id='last_name'
name='last_name' {...register('last_name')}
label='Отображаемая фамилия' label='Отображаемая фамилия'
autoComplete='family-name' autoComplete='family-name'
value={lastName} error={errors.last_name}
onChange={event => setLastName(event.target.value)}
/> />
</FlexColumn> </FlexColumn>
</div> </div>
@ -163,10 +149,10 @@ function FormSignup() {
</div> </div>
<div className='flex justify-around my-3'> <div className='flex justify-around my-3'>
<SubmitButton text='Регистрировать' className='min-w-[10rem]' loading={isPending} /> <SubmitButton text='Регистрировать' className='min-w-[10rem]' loading={isPending || !isValid} />
<Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{error ? <ProcessError error={error} /> : null} {serverError ? <ServerError error={serverError} /> : null}
</form> </form>
); );
} }
@ -174,17 +160,17 @@ function FormSignup() {
export default FormSignup; export default FormSignup;
// ====== Internals ========= // ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement { function ServerError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) { if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
if ('email' in error.response.data) { if ('email' in error.response.data) {
return ( return (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
<div className='mx-auto text-sm select-text text-warn-600'>{error.response.data.email}.</div> <div className='mx-auto text-sm select-text text-warn-600'>{error.response.data.email}</div>
); );
} else if ('username' in error.response.data) { } else if ('username' in error.response.data) {
return ( return (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
<div className='mx-auto text-sm select-text text-warn-600'>{error.response.data.username}.</div> <div className='mx-auto text-sm select-text text-warn-600'>{error.response.data.username}</div>
); );
} else { } else {
return ( return (

View File

@ -985,7 +985,12 @@ export const errors = {
imageFailed: 'Ошибка при создании изображения', imageFailed: 'Ошибка при создании изображения',
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении', reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении',
substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении', substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении',
inputAlreadyExists: 'Концептуальная схема с таким именем уже существует' inputAlreadyExists: 'Концептуальная схема с таким именем уже существует',
requiredField: 'Поле обязательно для заполнения',
emailField: 'Введите корректный адрес электронной почты',
rulesNotAccepted: 'Примите условия пользования Порталом',
privacyNotAccepted: 'Примите политику обработки персональных данных',
loginFormat: 'Имя пользователя должно содержать только буквы и цифры'
}; };
/** /**