F: Rework user profile editor

This commit is contained in:
Ivan 2025-02-03 18:48:29 +03:00
parent f5419472f5
commit 4cf24d0200
8 changed files with 70 additions and 63 deletions

View File

@ -103,6 +103,7 @@ class UserSerializer(serializers.ModelSerializer):
'first_name', 'first_name',
'last_name', 'last_name',
] ]
read_only_fields = ('id', 'username')
def validate(self, attrs): def validate(self, attrs):
attrs = super().validate(attrs) attrs = super().validate(attrs)

View File

@ -101,6 +101,10 @@ class TestUserUserProfileAPIView(EndpointTester):
data = {'email': self.user2.email} data = {'email': self.user2.email}
self.executeBadData(data=data) self.executeBadData(data=data)
data = {'username': 'new_username'}
response = self.executeOK(data=data)
self.assertNotEqual(response.data['username'], data['username'])
self.logout() self.logout()
self.executeForbidden() self.executeForbidden()

View File

@ -30,12 +30,16 @@ 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 { export const UpdateProfileSchema = z.object({
username: string; email: z.string().email(errors.emailField),
email: string; first_name: z.string(),
first_name: string; last_name: z.string()
last_name: string; });
}
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export type IUpdateProfileDTO = z.infer<typeof UpdateProfileSchema>;
export const usersApi = { export const usersApi = {
baseKey: 'users', baseKey: 'users',

View File

@ -1,5 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { IUserProfile } from '@/models/user';
import { DataCallback } from '../apiTransport';
import { IUpdateProfileDTO, usersApi } from './api'; import { IUpdateProfileDTO, usersApi } from './api';
export const useUpdateProfile = () => { export const useUpdateProfile = () => {
@ -13,7 +16,8 @@ export const useUpdateProfile = () => {
} }
}); });
return { return {
updateProfile: (data: IUpdateProfileDTO) => mutation.mutate(data), updateProfile: (data: IUpdateProfileDTO, onSuccess?: DataCallback<IUserProfile>) =>
mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,
error: mutation.error, error: mutation.error,
reset: mutation.reset reset: mutation.reset

View File

@ -35,7 +35,7 @@ function LoginPage() {
}); });
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const { login, isPending, error: serverError, reset } = useLogin(); const { login, isPending, error: serverError, reset: clearServerError } = useLogin();
function onSubmit(data: IUserLoginDTO) { function onSubmit(data: IUserLoginDTO) {
login(data, () => { login(data, () => {
@ -49,7 +49,7 @@ function LoginPage() {
} }
function resetErrors() { function resetErrors() {
reset(); clearServerError();
clearErrors(); clearErrors();
} }

View File

@ -6,7 +6,7 @@ import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IUserSignupDTO, UserSignupSchema } from '@/backend/users/api'; import { IUserSignupDTO, UserSignupSchema } from '@/backend/users/api';
import { useSignup } from '@/backend/users/useSignup'; import { useSignup } from '@/backend/users/useSignup';
@ -26,7 +26,7 @@ import { globals, patterns } from '@/utils/constants';
function FormSignup() { function FormSignup() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { signup, isPending, error: serverError, reset } = useSignup(); const { signup, isPending, error: serverError, reset: clearServerError } = useSignup();
const [acceptPrivacy, setAcceptPrivacy] = useState(false); const [acceptPrivacy, setAcceptPrivacy] = useState(false);
const [acceptRules, setAcceptRules] = useState(false); const [acceptRules, setAcceptRules] = useState(false);
@ -34,13 +34,15 @@ function FormSignup() {
register, register,
handleSubmit, handleSubmit,
clearErrors, clearErrors,
formState: { errors } formState: { errors, isDirty }
} = useForm<IUserSignupDTO>({ } = useForm<IUserSignupDTO>({
resolver: zodResolver(UserSignupSchema) resolver: zodResolver(UserSignupSchema)
}); });
useBlockNavigation(isDirty);
function resetErrors() { function resetErrors() {
reset(); clearServerError();
clearErrors(); clearErrors();
} }

View File

@ -16,7 +16,7 @@ import TextInput from '@/components/ui/TextInput';
function EditorPassword() { function EditorPassword() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { changePassword, isPending, error: serverError, reset } = useChangePassword(); const { changePassword, isPending, error: serverError, reset: clearServerError } = useChangePassword();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -27,7 +27,7 @@ function EditorPassword() {
}); });
function resetErrors() { function resetErrors() {
reset(); clearServerError();
clearErrors(); clearErrors();
} }

View File

@ -1,10 +1,11 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form';
import { useBlockNavigation } from '@/app/Navigation/NavigationContext'; import { useBlockNavigation } from '@/app/Navigation/NavigationContext';
import { IUpdateProfileDTO } from '@/backend/users/api'; import { IUpdateProfileDTO, UpdateProfileSchema } from '@/backend/users/api';
import { useProfileSuspense } from '@/backend/users/useProfile'; import { useProfileSuspense } from '@/backend/users/useProfile';
import { useUpdateProfile } from '@/backend/users/useUpdateProfile'; import { useUpdateProfile } from '@/backend/users/useUpdateProfile';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
@ -13,76 +14,67 @@ import TextInput from '@/components/ui/TextInput';
function EditorProfile() { function EditorProfile() {
const { profile } = useProfileSuspense(); const { profile } = useProfileSuspense();
const { updateProfile, isPending, error } = useUpdateProfile(); const { updateProfile, isPending, error: serverError, reset: clearServerError } = useUpdateProfile();
const [username, setUsername] = useState(profile.username); const {
const [email, setEmail] = useState(profile.email); register,
const [first_name, setFirstName] = useState(profile.first_name); handleSubmit,
const [last_name, setLastName] = useState(profile.last_name); clearErrors,
reset: resetForm,
formState: { errors, isDirty }
} = useForm<IUpdateProfileDTO>({
resolver: zodResolver(UpdateProfileSchema),
defaultValues: {
first_name: profile.first_name,
last_name: profile.last_name,
email: profile.email
}
});
const isModified = profile.email !== email || profile.first_name !== first_name || profile.last_name !== last_name; useBlockNavigation(isDirty);
useBlockNavigation(isModified); function resetErrors() {
clearServerError();
clearErrors();
}
useEffect(() => { function onSubmit(data: IUpdateProfileDTO) {
setUsername(profile.username); updateProfile(data, () => resetForm({ ...data }));
setEmail(profile.email);
setFirstName(profile.first_name);
setLastName(profile.last_name);
}, [profile]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data: IUpdateProfileDTO = {
username: username,
email: email,
first_name: first_name,
last_name: last_name
};
updateProfile(data);
} }
return ( return (
<form onSubmit={handleSubmit} className='cc-column w-[18rem] px-6 py-2'> <form
<TextInput className='cc-column w-[18rem] px-6 py-2'
id='username' onSubmit={event => void handleSubmit(onSubmit)(event)}
autoComplete='username' onChange={resetErrors}
disabled >
label='Логин' <TextInput id='username' disabled label='Логин' title='Логин изменить нельзя' value={profile.username} />
title='Логин изменить нельзя'
value={username}
/>
<TextInput <TextInput
id='first_name' id='first_name'
{...register('first_name')}
autoComplete='off' autoComplete='off'
allowEnter allowEnter
label='Имя' label='Имя'
value={first_name} error={errors.first_name}
onChange={event => setFirstName(event.target.value)}
/> />
<TextInput <TextInput
id='last_name' id='last_name'
{...register('last_name')}
autoComplete='off' autoComplete='off'
allowEnter allowEnter
label='Фамилия' label='Фамилия'
value={last_name} error={errors.last_name}
onChange={event => setLastName(event.target.value)}
/> />
<TextInput <TextInput
id='email' id='email'
{...register('email')}
autoComplete='off' autoComplete='off'
allowEnter allowEnter
label='Электронная почта' label='Электронная почта'
value={email} error={errors.email}
onChange={event => setEmail(event.target.value)}
/>
{error ? <ProcessError error={error} /> : null}
<SubmitButton
className='self-center mt-6'
text='Сохранить данные'
loading={isPending}
disabled={!isModified || email == ''}
/> />
{serverError ? <ServerError error={serverError} /> : null}
<SubmitButton className='self-center mt-6' text='Сохранить данные' loading={isPending} />
</form> </form>
); );
} }
@ -90,7 +82,7 @@ function EditorProfile() {
export default EditorProfile; export default EditorProfile;
// ====== 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 (