F: Implement login form using react-hook-form
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled

This commit is contained in:
Ivan 2025-01-31 21:05:04 +03:00
parent c97b8997ce
commit 27234c488d
19 changed files with 141 additions and 72 deletions

View File

@ -39,6 +39,7 @@ This readme file is used mostly to document project dependencies and conventions
- react-error-boundary - react-error-boundary
- react-tooltip - react-tooltip
- react-zoom-pan-pinch - react-zoom-pan-pinch
- react-hook-form
- reactflow - reactflow
- js-file-download - js-file-download
- use-debounce - use-debounce
@ -46,6 +47,7 @@ This readme file is used mostly to document project dependencies and conventions
- html-to-image - html-to-image
- zustand - zustand
- zod - zod
- @hookform/resolvers
- @tanstack/react-table - @tanstack/react-table
- @tanstack/react-query - @tanstack/react-query
- @tanstack/react-query-devtools - @tanstack/react-query-devtools

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^3.10.0",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.2", "@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2", "@tanstack/react-query-devtools": "^5.64.2",
@ -23,6 +24,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-intl": "^7.1.5", "react-intl": "^7.1.5",
"react-router": "^7.1.3", "react-router": "^7.1.3",
@ -1699,6 +1701,15 @@
"tslib": "2" "tslib": "2"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -9102,6 +9113,22 @@
"react": ">=16.13.1" "react": ">=16.13.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^3.10.0",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.2", "@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2", "@tanstack/react-query-devtools": "^5.64.2",
@ -27,6 +28,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-intl": "^7.1.5", "react-intl": "^7.1.5",
"react-router": "^7.1.3", "react-router": "^7.1.3",

View File

@ -11,6 +11,7 @@ interface UserButtonProps {
function UserButton({ onLogin, onClickUser }: UserButtonProps) { function UserButton({ onLogin, onClickUser }: UserButtonProps) {
const { user, isAnonymous } = useAuthSuspense(); const { user, isAnonymous } = useAuthSuspense();
console.log(user);
const adminMode = usePreferencesStore(state => state.adminMode); const adminMode = usePreferencesStore(state => state.adminMode);
if (isAnonymous) { if (isAnonymous) {
return ( return (

View File

@ -10,8 +10,8 @@ import { 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(), username: z.string().nonempty('Поле логина обязательно для заполнения'),
password: z.string() password: z.string().nonempty('Поле пароля обязательно для заполнения')
}); });
/** /**

View File

@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { libraryApi } from '@/backend/library/api';
import { authApi, IUserLoginDTO } from './api'; import { authApi, IUserLoginDTO } from './api';
@ -10,10 +9,11 @@ export const useLogin = () => {
mutationKey: ['login'], mutationKey: ['login'],
mutationFn: authApi.login, mutationFn: authApi.login,
onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }), onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }),
onSuccess: () => client.removeQueries({ queryKey: [libraryApi.baseKey] }) onSuccess: () => client.resetQueries()
}); });
return { return {
login: (data: IUserLoginDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }), login: (data: IUserLoginDTO, onSuccess?: () => void, onError?: (error: AxiosError) => void) =>
mutation.mutate(data, { onSuccess, onError }),
isPending: mutation.isPending, isPending: mutation.isPending,
error: mutation.error, error: mutation.error,
reset: mutation.reset reset: mutation.reset

View File

@ -7,8 +7,7 @@ export const useLogout = () => {
const mutation = useMutation({ const mutation = useMutation({
mutationKey: ['logout'], mutationKey: ['logout'],
mutationFn: authApi.logout, mutationFn: authApi.logout,
onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }), onSuccess: () => client.resetQueries()
onSuccess: () => client.removeQueries()
}); });
return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) }; return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) };
}; };

View File

@ -2,9 +2,17 @@ import { queryOptions } from '@tanstack/react-query';
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, IUserSignupData } from '@/models/user'; import { IUser, IUserInfo, IUserProfile } from '@/models/user';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
/**
* Represents signup data, used to create new users.
*/
export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> {
password: string;
password2: string;
}
/** /**
* Represents user data, intended to update user profile in persistent storage. * Represents user data, intended to update user profile in persistent storage.
*/ */

View File

@ -1,8 +1,8 @@
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 { usersApi } from '@/backend/users/api'; import { IUserSignupData, usersApi } from '@/backend/users/api';
import { IUserProfile, IUserSignupData } from '@/models/user'; import { IUserProfile } from '@/models/user';
export const useSignup = () => { export const useSignup = () => {
const client = useQueryClient(); const client = useQueryClient();

View File

@ -1,5 +1,6 @@
// =========== Module contains interfaces for common UI elements. ========== // =========== Module contains interfaces for common UI elements. ==========
import React from 'react'; import React from 'react';
import { FieldError } from 'react-hook-form';
export namespace CProps { export namespace CProps {
/** /**
@ -35,6 +36,13 @@ export namespace CProps {
hideTitle?: boolean; hideTitle?: boolean;
} }
/**
* Represents an object that can have an error message.
*/
export interface ErrorProcessing {
error?: FieldError;
}
/** /**
* Represents `control` component with optional title and configuration options. * Represents `control` component with optional title and configuration options.
* *

View File

@ -0,0 +1,17 @@
import { FieldError, GlobalError } from 'react-hook-form';
interface ErrorFieldProps {
error?: FieldError | GlobalError;
}
/**
* Displays an error message for input field.
*/
function ErrorField({ error }: ErrorFieldProps) {
if (!error) {
return null;
}
return <div className='text-sm text-warn-600'>{error.message}</div>;
}
export default ErrorField;

View File

@ -2,9 +2,10 @@ import clsx from 'clsx';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import ErrorField from './ErrorField';
import Label from './Label'; import Label from './Label';
interface TextInputProps extends CProps.Editor, CProps.Colors, CProps.Input { interface TextInputProps extends CProps.Editor, CProps.ErrorProcessing, CProps.Colors, CProps.Input {
/** Indicates that padding should be minimal. */ /** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
@ -32,6 +33,7 @@ function TextInput({
className, className,
colors = 'clr-input', colors = 'clr-input',
onKeyDown, onKeyDown,
error,
...restProps ...restProps
}: TextInputProps) { }: TextInputProps) {
return ( return (
@ -63,6 +65,7 @@ function TextInput({
disabled={disabled} disabled={disabled}
{...restProps} {...restProps}
/> />
<ErrorField error={error} />
</div> </div>
); );
} }

View File

@ -48,7 +48,7 @@ function DlgChangeLocation() {
<SelectLocationHead <SelectLocationHead
value={head} // prettier: split-lines value={head} // prettier: split-lines
onChange={setHead} onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []}
/> />
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} className='max-h-[9.2rem]' /> <SelectLocationContext value={location} onChange={handleSelectLocation} className='max-h-[9.2rem]' />

View File

@ -119,11 +119,7 @@ function DlgCloneLibraryItem() {
<div className='flex justify-between gap-3'> <div className='flex justify-between gap-3'>
<div className='flex flex-col gap-2 w-[7rem] h-min'> <div className='flex flex-col gap-2 w-[7rem] h-min'>
<Label text='Корень' /> <Label text='Корень' />
<SelectLocationHead <SelectLocationHead value={head} onChange={setHead} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []} />
value={head}
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} /> <SelectLocationContext value={location} onChange={handleSelectLocation} />
<TextArea <TextArea

View File

@ -32,14 +32,6 @@ export interface ICurrentUser {
editor: LibraryItemID[]; editor: LibraryItemID[];
} }
/**
* Represents signup data, used to create new users.
*/
export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> {
password: string;
password2: string;
}
/** /**
* Represents user profile for viewing and editing {@link IUser}. * Represents user profile for viewing and editing {@link IUser}.
*/ */

View File

@ -196,11 +196,7 @@ function FormCreateItem() {
<div className='flex justify-between gap-3 flex-grow'> <div className='flex justify-between gap-3 flex-grow'>
<div className='flex flex-col gap-2 min-w-[7rem] h-min'> <div className='flex flex-col gap-2 min-w-[7rem] h-min'>
<Label text='Корень' /> <Label text='Корень' />
<SelectLocationHead <SelectLocationHead value={head} onChange={setHead} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []} />
value={head}
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} /> <SelectLocationContext value={location} onChange={handleSelectLocation} />
<TextArea <TextArea

View File

@ -34,7 +34,7 @@ function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocationProps
const toggleSubfolders = useLibrarySearchStore(state => state.toggleSubfolders); const toggleSubfolders = useLibrarySearchStore(state => state.toggleSubfolders);
const canRename = (() => { const canRename = (() => {
if (location.length <= 3 || isAnonymous || !user) { if (location.length <= 3 || isAnonymous) {
return false; return false;
} }
if (user.is_staff) { if (user.is_staff) {

View File

@ -1,16 +1,18 @@
'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 { 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 { UserLoginSchema } from '@/backend/auth/api'; import { IUserLoginDTO, UserLoginSchema } from '@/backend/auth/api';
import { useAuthSuspense } from '@/backend/auth/useAuth'; import { useAuthSuspense } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin'; import { useLogin } from '@/backend/auth/useLogin';
import ExpectedAnonymous from '@/components/ExpectedAnonymous'; import ExpectedAnonymous from '@/components/ExpectedAnonymous';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
import ErrorField from '@/components/ui/ErrorField';
import SubmitButton from '@/components/ui/SubmitButton'; import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL'; import TextURL from '@/components/ui/TextURL';
@ -22,65 +24,74 @@ function LoginPage() {
const query = useQueryStrings(); const query = useQueryStrings();
const initialName = query.get('username') ?? ''; const initialName = query.get('username') ?? '';
const { isAnonymous } = useAuthSuspense(); const {
const { login, isPending, error: loginError, reset } = useLogin(); register,
const [validationError, setValidationError] = useState<ErrorData | undefined>(undefined); handleSubmit,
clearErrors,
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { setError,
event.preventDefault(); resetField,
if (!isPending) { formState: { errors }
const formData = new FormData(event.currentTarget); } = useForm({
const result = UserLoginSchema.safeParse({ resolver: zodResolver(UserLoginSchema),
username: formData.get('username'), defaultValues: { username: initialName, password: '' }
password: formData.get('password')
}); });
if (!result.success) { const { isAnonymous } = useAuthSuspense();
setValidationError(result.error); const { login, isPending, error: loginError, reset } = useLogin();
} else {
login(result.data, () => { function onSubmit(data: IUserLoginDTO) {
login(
data,
() => {
resetField('password');
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push(urls.library);
} }
}); },
error => {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
setError('root', { message: 'На Портале отсутствует такое сочетание имени пользователя и пароля' });
} }
} }
);
} }
function resetErrors() { function resetErrors() {
reset(); reset();
setValidationError(undefined); clearErrors();
} }
if (!isAnonymous) { if (!isAnonymous) {
return <ExpectedAnonymous />; return <ExpectedAnonymous />;
} }
return ( return (
<form className={clsx('cc-column cc-fade-in', 'w-[24rem] mx-auto', 'pt-12 pb-6 px-6')} onSubmit={handleSubmit}> <form
className={clsx('cc-column cc-fade-in', 'w-[24rem] mx-auto', 'pt-12 pb-6 px-6')}
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>
<img alt='Концепт Портал' src={resources.logo} className='max-h-[2.5rem] min-w-[2.5rem] mb-3' /> <img alt='Концепт Портал' src={resources.logo} className='max-h-[2.5rem] min-w-[2.5rem] mb-3' />
<TextInput <TextInput
id='username' id='username'
name='username'
autoComplete='username' autoComplete='username'
label='Логин или email' label='Логин или email'
{...register('username', { required: true })}
autoFocus autoFocus
required
allowEnter allowEnter
spellCheck={false} spellCheck={false}
defaultValue={initialName} defaultValue={initialName}
onChange={resetErrors} error={errors.username}
/> />
<TextInput <TextInput
id='password' id='password'
name='password' {...register('password', { required: true })}
type='password' type='password'
autoComplete='current-password' autoComplete='current-password'
label='Пароль' label='Пароль'
required
allowEnter allowEnter
onChange={resetErrors} error={errors.password}
/> />
<SubmitButton text='Войти' className='self-center w-[12rem] mt-3' loading={isPending} /> <SubmitButton text='Войти' className='self-center w-[12rem] mt-3' loading={isPending} />
@ -88,7 +99,8 @@ function LoginPage() {
<TextURL text='Восстановить пароль...' href='/restore-password' /> <TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div> </div>
{!!loginError || !!validationError ? <ProcessError error={loginError ?? validationError} /> : null} <ErrorField error={errors.root} />
<EscalateError error={loginError} />
</form> </form>
); );
} }
@ -96,13 +108,13 @@ function LoginPage() {
export default LoginPage; export default LoginPage;
// ====== Internals ========= // ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement { function EscalateError({ error }: { error: ErrorData }): React.ReactElement | null {
// TODO: rework error escalation mechanism. Probably make it global.
if (!error) {
return null;
}
if (axios.isAxiosError(error) && error.response && error.response.status === 400) { if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return ( return null;
<div className='text-sm select-text text-warn-600'>
На Портале отсутствует такое сочетание имени пользователя и пароля
</div>
);
} }
throw error as Error; throw error as Error;
} }

View File

@ -35,7 +35,7 @@ function FormSignup() {
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 isValid = acceptPrivacy && acceptRules && !!email && !!username;
useEffect(() => { useEffect(() => {
reset(); reset();
@ -87,6 +87,7 @@ function FormSignup() {
<FlexColumn> <FlexColumn>
<TextInput <TextInput
id='username' id='username'
name='username'
autoComplete='username' autoComplete='username'
required required
label='Имя пользователя (логин)' label='Имя пользователя (логин)'
@ -100,6 +101,7 @@ function FormSignup() {
<TextInput <TextInput
id='password' id='password'
type='password' type='password'
name='password'
autoComplete='new-password' autoComplete='new-password'
required required
label='Пароль' label='Пароль'
@ -110,6 +112,7 @@ function FormSignup() {
<TextInput <TextInput
id='password2' id='password2'
type='password' type='password'
name='password2'
label='Повторите пароль' label='Повторите пароль'
autoComplete='new-password' autoComplete='new-password'
required required
@ -122,6 +125,7 @@ function FormSignup() {
<FlexColumn className='w-[15rem]'> <FlexColumn className='w-[15rem]'>
<TextInput <TextInput
id='email' id='email'
name='email'
autoComplete='email' autoComplete='email'
required required
spellCheck={false} spellCheck={false}
@ -132,6 +136,7 @@ function FormSignup() {
/> />
<TextInput <TextInput
id='first_name' id='first_name'
name='first_name'
label='Отображаемое имя' label='Отображаемое имя'
autoComplete='given-name' autoComplete='given-name'
value={firstName} value={firstName}
@ -139,6 +144,7 @@ function FormSignup() {
/> />
<TextInput <TextInput
id='last_name' id='last_name'
name='last_name'
label='Отображаемая фамилия' label='Отображаемая фамилия'
autoComplete='family-name' autoComplete='family-name'
value={lastName} value={lastName}
@ -157,7 +163,7 @@ 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} disabled={!isValid} /> <SubmitButton text='Регистрировать' className='min-w-[10rem]' loading={isPending} />
<Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}