From e273c29c36e4c1ca99024af6d699201796e0d0a3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 15:04:49 +0500 Subject: [PATCH] refactor(SettingsPage): split into ProfileSection, AvatarSection, AuthMethodsSection - Extract ProfileSection (45 lines): display name form with save button - Extract AvatarSection (114 lines): avatar preview, style selector, generate/save/cancel - Extract AuthMethodsSection (204 lines): auth methods list, set/change password forms - Rewrite SettingsPage as composer (41 lines): composes 3 sections with dividers - Add tests for all 3 sections --- .../me/ui/sections/AuthMethodsSection.tsx | 204 ++++++++++ .../pages/me/ui/sections/AvatarSection.tsx | 114 ++++++ .../pages/me/ui/sections/ProfileSection.tsx | 45 +++ .../src/pages/me/ui/sections/SettingsPage.tsx | 368 +----------------- .../__tests__/AuthMethodsSection.test.tsx | 44 +++ .../sections/__tests__/AvatarSection.test.tsx | 36 ++ .../__tests__/ProfileSection.test.tsx | 23 ++ 7 files changed, 473 insertions(+), 361 deletions(-) create mode 100644 client/src/pages/me/ui/sections/AuthMethodsSection.tsx create mode 100644 client/src/pages/me/ui/sections/AvatarSection.tsx create mode 100644 client/src/pages/me/ui/sections/ProfileSection.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx new file mode 100644 index 0000000..4bdda00 --- /dev/null +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { apiClient } from '@/shared/api/client' +import { $user, fetchAuthMethodsFx, setPasswordFx, unlinkOAuthFx, type AuthMethod } from '@/shared/model/auth' + +const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } + +export function AuthMethodsSection() { + const user = useUnit($user) + + const [authMethods, setAuthMethods] = useState([]) + const [showSetPassword, setShowSetPassword] = useState(false) + const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, + }) + + useEffect(() => { + fetchAuthMethodsFx() + .then(setAuthMethods) + .catch(() => { + setAuthMethods([]) + }) + }, []) + + const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, + }) + + const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, + }) + + const [showChangePassword, setShowChangePassword] = useState(false) + const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ + defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, + }) + + const changePasswordMutation = useMutation({ + mutationFn: async (params: { oldPassword: string; newPassword: string }) => { + await apiClient.post('me/change-password', params) + }, + onSuccess: () => { + setShowChangePassword(false) + changePasswordForm.reset() + }, + }) + + const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length + }, [authMethods]) + + if (!user) return null + + return ( + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {m.active && m.type === 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + + + {showSetPassword && ( + + + + + + + + + )} + + {showChangePassword && ( + + + + + + + + + + )} + + ) +} diff --git a/client/src/pages/me/ui/sections/AvatarSection.tsx b/client/src/pages/me/ui/sections/AvatarSection.tsx new file mode 100644 index 0000000..dd5b656 --- /dev/null +++ b/client/src/pages/me/ui/sections/AvatarSection.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { createAvatar } from '@dicebear/core' +import { useUnit } from 'effector-react' +import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' +import { $user, updateProfileFx } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' + +export function AvatarSection() { + const user = useUnit($user) + const pendingProfile = useUnit(updateProfileFx.pending) + + const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) + const [previewSrc, setPreviewSrc] = useState(null) + const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) + + const hasUnsavedPreview = previewSrc !== null + + if (!user) return null + + return ( + + + Аватар + + + + + + + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} + + + {hasUnsavedPreview && ( + + + + Текущий + + + )} + + + + + Стиль + + + + + + {hasUnsavedPreview && ( + + + + + )} + + ) +} diff --git a/client/src/pages/me/ui/sections/ProfileSection.tsx b/client/src/pages/me/ui/sections/ProfileSection.tsx new file mode 100644 index 0000000..11ff3f1 --- /dev/null +++ b/client/src/pages/me/ui/sections/ProfileSection.tsx @@ -0,0 +1,45 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { $user, updateProfileFx } from '@/shared/model/auth' + +export function ProfileSection() { + const user = useUnit($user) + const pendingProfile = useUnit(updateProfileFx.pending) + + const profileForm = useForm<{ displayName: string }>({ + defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, + mode: 'onChange', + }) + + return ( + + + Профиль + + + + + + + ) +} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index df6635a..dd4035a 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -1,113 +1,16 @@ -import { useCallback, useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Chip from '@mui/material/Chip' import Divider from '@mui/material/Divider' -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' import Stack from '@mui/material/Stack' -import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' -import { createAvatar } from '@dicebear/core' -import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { useForm } from 'react-hook-form' -import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' -import { - $updateProfileError, - $user, - fetchAuthMethodsFx, - setPasswordFx, - unlinkOAuthFx, - updateProfileFx, - type AuthMethod, -} from '@/shared/model/auth' -import { UserAvatar } from '@/shared/ui/UserAvatar' -import { apiClient } from '@/shared/api/client' -import type { AxiosError } from 'axios' - -function getApiErrorMessage(error: unknown): string | null { - const e = error as AxiosError<{ error?: string }> - const msg = e?.response?.data?.error - return msg ? String(msg) : null -} +import { $user } from '@/shared/model/auth' +import { AuthMethodsSection } from './AuthMethodsSection' +import { AvatarSection } from './AvatarSection' +import { ProfileSection } from './ProfileSection' export function SettingsPage() { const user = useUnit($user) - const pendingProfile = useUnit(updateProfileFx.pending) - const errorProfile = useUnit($updateProfileError) - - const profileForm = useForm<{ displayName: string }>({ - defaultValues: { - displayName: user?.displayName ? String(user.displayName) : '', - }, - mode: 'onChange', - }) - - const profileErrorMsg = getApiErrorMessage(errorProfile) - - const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) - const [previewSrc, setPreviewSrc] = useState(null) - const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) - - const hasUnsavedPreview = previewSrc !== null - - const [authMethods, setAuthMethods] = useState([]) - const [showSetPassword, setShowSetPassword] = useState(false) - const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ - defaultValues: { password: '', passwordConfirm: '' }, - }) - - useEffect(() => { - fetchAuthMethodsFx() - .then(setAuthMethods) - .catch(() => { - setAuthMethods([]) - }) - }, []) - - const setPasswordMutation = useMutation({ - mutationFn: async (pw: string) => { - await setPasswordFx(pw) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - setShowSetPassword(false) - }, - onError: () => {}, - }) - - const unlinkMutation = useMutation({ - mutationFn: async (provider: 'vk' | 'yandex') => { - await unlinkOAuthFx(provider) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - }, - onError: () => {}, - }) - - const [showChangePassword, setShowChangePassword] = useState(false) - const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ - defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, - }) - - const changePasswordMutation = useMutation({ - mutationFn: async (params: { oldPassword: string; newPassword: string }) => { - await apiClient.post('me/change-password', params) - }, - onSuccess: () => { - setShowChangePassword(false) - changePasswordForm.reset() - }, - }) - - const linkedCount = useCallback(() => { - return authMethods.filter((m) => m.active).length - }, [authMethods]) - - const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } if (!user) { return Нужно войти. Перейдите на страницу «Вход». @@ -122,271 +25,14 @@ export function SettingsPage() { Текущая почта: {user.email} - {profileErrorMsg && ( - - {profileErrorMsg} - - )} - - - - Профиль - - - - - - - + - - - - Аватар - - - - - - - {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} - - - {hasUnsavedPreview && ( - - - - Текущий - - - )} - - - - - Стиль - - - - - - {hasUnsavedPreview && ( - - - - - )} - - + {!user.isAdmin && ( <> - - - Методы входа - - - {authMethods.map((m) => ( - - {METHOD_LABELS[m.type] || m.type} - - {m.active && m.type !== 'password' && ( - - )} - {m.active && m.type === 'password' && ( - - )} - {!m.active && m.type === 'password' && ( - - )} - {!m.active && m.type !== 'password' && ( - - )} - - ))} - - - {showSetPassword && ( - - - - - - - - - )} - - {showChangePassword && ( - - - - - - - - - - )} - + )} diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx new file mode 100644 index 0000000..06c425d --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AuthMethodsSection } from '../AuthMethodsSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: { + defaultState: { id: '1', email: 'test@test.com' }, + subscribe: () => () => {}, + getState: () => ({ id: '1', email: 'test@test.com' }), + watch: () => () => {}, + on: () => {}, + reset: () => {}, + }, + fetchAuthMethodsFx: vi.fn().mockResolvedValue([]), + setPasswordFx: vi.fn(), + unlinkOAuthFx: vi.fn(), +})) + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) + +vi.mock('effector-react', async () => { + return { + useUnit: () => ({ id: '1', email: 'test@test.com' }), + } +}) + +function renderSection() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + , + ) +} + +describe('AuthMethodsSection', () => { + it('renders auth methods section', async () => { + renderSection() + await waitFor(() => { + expect(screen.getByText('Методы входа')).toBeTruthy() + }) + }) +}) diff --git a/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx new file mode 100644 index 0000000..447dc34 --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AvatarSection } from '../AvatarSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: { + defaultState: { id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }, + subscribe: () => () => {}, + getState: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }), + watch: () => () => {}, + on: () => {}, + reset: () => {}, + }, + updateProfileFx: { pending: false }, +})) + +vi.mock('effector-react', async () => { + return { + useUnit: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }), + } +}) + +vi.mock('@dicebear/core', () => ({ + createAvatar: vi.fn(() => ({ + toDataUri: () => 'data:image/svg+xml,', + })), +})) + +describe('AvatarSection', () => { + it('renders avatar section', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Аватар')).toBeTruthy() + }) + }) +}) diff --git a/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx new file mode 100644 index 0000000..0f3d448 --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ProfileSection } from '../ProfileSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: null, + $updateProfileError: null, + updateProfileFx: { pending: false }, +})) + +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +describe('ProfileSection', () => { + it('renders profile section', () => { + render() + expect(screen.getByText('Профиль')).toBeTruthy() + expect(screen.getByLabelText('Имя или ник')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Сохранить' })).toBeTruthy() + }) +})