F: Rework signup form using react-hook-form and zod
This commit is contained in:
parent
18979dbaa3
commit
9aa23aedfb
|
@ -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)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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='Пароль'
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -985,7 +985,12 @@ export const errors = {
|
||||||
imageFailed: 'Ошибка при создании изображения',
|
imageFailed: 'Ошибка при создании изображения',
|
||||||
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении',
|
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении',
|
||||||
substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении',
|
substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении',
|
||||||
inputAlreadyExists: 'Концептуальная схема с таким именем уже существует'
|
inputAlreadyExists: 'Концептуальная схема с таким именем уже существует',
|
||||||
|
requiredField: 'Поле обязательно для заполнения',
|
||||||
|
emailField: 'Введите корректный адрес электронной почты',
|
||||||
|
rulesNotAccepted: 'Примите условия пользования Порталом',
|
||||||
|
privacyNotAccepted: 'Примите политику обработки персональных данных',
|
||||||
|
loginFormat: 'Имя пользователя должно содержать только буквы и цифры'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue
Block a user