diff --git a/rsconcept/backend/apps/users/serializers.py b/rsconcept/backend/apps/users/serializers.py index f074aa66..4dc1f13d 100644 --- a/rsconcept/backend/apps/users/serializers.py +++ b/rsconcept/backend/apps/users/serializers.py @@ -103,6 +103,7 @@ class UserSerializer(serializers.ModelSerializer): 'first_name', 'last_name', ] + read_only_fields = ('id', 'username') def validate(self, attrs): attrs = super().validate(attrs) diff --git a/rsconcept/backend/apps/users/tests/t_views.py b/rsconcept/backend/apps/users/tests/t_views.py index d73c232d..8999bb28 100644 --- a/rsconcept/backend/apps/users/tests/t_views.py +++ b/rsconcept/backend/apps/users/tests/t_views.py @@ -101,6 +101,10 @@ class TestUserUserProfileAPIView(EndpointTester): data = {'email': self.user2.email} self.executeBadData(data=data) + data = {'username': 'new_username'} + response = self.executeOK(data=data) + self.assertNotEqual(response.data['username'], data['username']) + self.logout() self.executeForbidden() diff --git a/rsconcept/frontend/src/backend/users/api.ts b/rsconcept/frontend/src/backend/users/api.ts index 968c0507..83604031 100644 --- a/rsconcept/frontend/src/backend/users/api.ts +++ b/rsconcept/frontend/src/backend/users/api.ts @@ -30,12 +30,16 @@ export type IUserSignupDTO = z.infer; /** * Represents user data, intended to update user profile in persistent storage. */ -export interface IUpdateProfileDTO { - username: string; - email: string; - first_name: string; - last_name: string; -} +export const UpdateProfileSchema = z.object({ + email: z.string().email(errors.emailField), + first_name: z.string(), + last_name: z.string() +}); + +/** + * Represents user data, intended to update user profile in persistent storage. + */ +export type IUpdateProfileDTO = z.infer; export const usersApi = { baseKey: 'users', diff --git a/rsconcept/frontend/src/backend/users/useUpdateProfile.tsx b/rsconcept/frontend/src/backend/users/useUpdateProfile.tsx index 654c4fe7..ae28a59d 100644 --- a/rsconcept/frontend/src/backend/users/useUpdateProfile.tsx +++ b/rsconcept/frontend/src/backend/users/useUpdateProfile.tsx @@ -1,5 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { IUserProfile } from '@/models/user'; + +import { DataCallback } from '../apiTransport'; import { IUpdateProfileDTO, usersApi } from './api'; export const useUpdateProfile = () => { @@ -13,7 +16,8 @@ export const useUpdateProfile = () => { } }); return { - updateProfile: (data: IUpdateProfileDTO) => mutation.mutate(data), + updateProfile: (data: IUpdateProfileDTO, onSuccess?: DataCallback) => + mutation.mutate(data, { onSuccess }), isPending: mutation.isPending, error: mutation.error, reset: mutation.reset diff --git a/rsconcept/frontend/src/pages/LoginPage.tsx b/rsconcept/frontend/src/pages/LoginPage.tsx index 4ed1112b..666281e4 100644 --- a/rsconcept/frontend/src/pages/LoginPage.tsx +++ b/rsconcept/frontend/src/pages/LoginPage.tsx @@ -35,7 +35,7 @@ function LoginPage() { }); const { isAnonymous } = useAuthSuspense(); - const { login, isPending, error: serverError, reset } = useLogin(); + const { login, isPending, error: serverError, reset: clearServerError } = useLogin(); function onSubmit(data: IUserLoginDTO) { login(data, () => { @@ -49,7 +49,7 @@ function LoginPage() { } function resetErrors() { - reset(); + clearServerError(); clearErrors(); } diff --git a/rsconcept/frontend/src/pages/RegisterPage/FormSignup.tsx b/rsconcept/frontend/src/pages/RegisterPage/FormSignup.tsx index 26effbb8..24224c47 100644 --- a/rsconcept/frontend/src/pages/RegisterPage/FormSignup.tsx +++ b/rsconcept/frontend/src/pages/RegisterPage/FormSignup.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { useState } from 'react'; 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 { IUserSignupDTO, UserSignupSchema } from '@/backend/users/api'; import { useSignup } from '@/backend/users/useSignup'; @@ -26,7 +26,7 @@ import { globals, patterns } from '@/utils/constants'; function FormSignup() { const router = useConceptNavigation(); - const { signup, isPending, error: serverError, reset } = useSignup(); + const { signup, isPending, error: serverError, reset: clearServerError } = useSignup(); const [acceptPrivacy, setAcceptPrivacy] = useState(false); const [acceptRules, setAcceptRules] = useState(false); @@ -34,13 +34,15 @@ function FormSignup() { register, handleSubmit, clearErrors, - formState: { errors } + formState: { errors, isDirty } } = useForm({ resolver: zodResolver(UserSignupSchema) }); + useBlockNavigation(isDirty); + function resetErrors() { - reset(); + clearServerError(); clearErrors(); } diff --git a/rsconcept/frontend/src/pages/UserProfilePage/EditorPassword.tsx b/rsconcept/frontend/src/pages/UserProfilePage/EditorPassword.tsx index b01b3ac6..6692cb5d 100644 --- a/rsconcept/frontend/src/pages/UserProfilePage/EditorPassword.tsx +++ b/rsconcept/frontend/src/pages/UserProfilePage/EditorPassword.tsx @@ -16,7 +16,7 @@ import TextInput from '@/components/ui/TextInput'; function EditorPassword() { const router = useConceptNavigation(); - const { changePassword, isPending, error: serverError, reset } = useChangePassword(); + const { changePassword, isPending, error: serverError, reset: clearServerError } = useChangePassword(); const { register, handleSubmit, @@ -27,7 +27,7 @@ function EditorPassword() { }); function resetErrors() { - reset(); + clearServerError(); clearErrors(); } diff --git a/rsconcept/frontend/src/pages/UserProfilePage/EditorProfile.tsx b/rsconcept/frontend/src/pages/UserProfilePage/EditorProfile.tsx index 95a340c4..a6399ce9 100644 --- a/rsconcept/frontend/src/pages/UserProfilePage/EditorProfile.tsx +++ b/rsconcept/frontend/src/pages/UserProfilePage/EditorProfile.tsx @@ -1,10 +1,11 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import axios from 'axios'; -import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; 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 { useUpdateProfile } from '@/backend/users/useUpdateProfile'; import { ErrorData } from '@/components/info/InfoError'; @@ -13,76 +14,67 @@ import TextInput from '@/components/ui/TextInput'; function EditorProfile() { const { profile } = useProfileSuspense(); - const { updateProfile, isPending, error } = useUpdateProfile(); + const { updateProfile, isPending, error: serverError, reset: clearServerError } = useUpdateProfile(); - const [username, setUsername] = useState(profile.username); - const [email, setEmail] = useState(profile.email); - const [first_name, setFirstName] = useState(profile.first_name); - const [last_name, setLastName] = useState(profile.last_name); + const { + register, + handleSubmit, + clearErrors, + reset: resetForm, + formState: { errors, isDirty } + } = useForm({ + 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(() => { - setUsername(profile.username); - setEmail(profile.email); - setFirstName(profile.first_name); - setLastName(profile.last_name); - }, [profile]); - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const data: IUpdateProfileDTO = { - username: username, - email: email, - first_name: first_name, - last_name: last_name - }; - updateProfile(data); + function onSubmit(data: IUpdateProfileDTO) { + updateProfile(data, () => resetForm({ ...data })); } return ( -
- + void handleSubmit(onSubmit)(event)} + onChange={resetErrors} + > + setFirstName(event.target.value)} + error={errors.first_name} /> setLastName(event.target.value)} + error={errors.last_name} /> setEmail(event.target.value)} - /> - {error ? : null} - + {serverError ? : null} + ); } @@ -90,7 +82,7 @@ function EditorProfile() { export default EditorProfile; // ====== 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 ('email' in error.response.data) { return (