codex/fsd-frontend-refactor #31
@ -1,15 +1 @@
|
|||||||
import { createContext } from 'react';
|
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export interface SessionContextValue {
|
|
||||||
user: UserResponse | null;
|
|
||||||
accessToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionContext = createContext<SessionContextValue | null>(null);
|
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { SessionContext, type SessionState } from './sessionContext';
|
import { SessionContext } from './sessionContext';
|
||||||
import { useSession } from './useSession';
|
import { useSession } from './useSession';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
user: { id: number; email: string; name: string; role: string } | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
register: () => Promise<void>;
|
||||||
|
updateProfile: () => Promise<void>;
|
||||||
|
refreshSession: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const mockSession: SessionState = {
|
const mockSession: SessionState = {
|
||||||
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
||||||
accessToken: 'mock-access-token',
|
accessToken: 'mock-access-token',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
login: vi.fn().mockResolvedValue(undefined),
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
register: vi.fn().mockResolvedValue(undefined),
|
register: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
15
apps/frontend/src/shared/lib/session-context.ts
Normal file
15
apps/frontend/src/shared/lib/session-context.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export interface SessionContextValue {
|
||||||
|
user: UserResponse | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
@ -1,81 +1,55 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { type ReactNode, useEffect, useState, useCallback } from 'react';
|
||||||
import { SessionContext, type SessionContextValue } from '@/entities/session';
|
import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||||
import * as sessionApi from '@/entities/session';
|
import * as sessionApi from '@/entities/session/api/sessionApi';
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export function TestSessionProvider({ children }: { children: ReactNode }) {
|
export function TestSessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<UserResponse | null>(null);
|
const [user, setUser] = useState<SessionContextValue['user']>(null);
|
||||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
|
||||||
setUser(authData.user);
|
|
||||||
setAccessTokenState(authData.accessToken);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
|
||||||
setUser(null);
|
|
||||||
setAccessTokenState(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
async (email: string, password: string) => {
|
|
||||||
const result = await sessionApi.login(email, password);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = useCallback(
|
|
||||||
async (email: string, password: string, name?: string) => {
|
|
||||||
const result = await sessionApi.register(email, password, name);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await sessionApi.logout();
|
|
||||||
} catch {
|
|
||||||
// ignore network errors on logout
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
}, [clearSession]);
|
|
||||||
|
|
||||||
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
|
||||||
const result = await sessionApi.updateProfile(data);
|
|
||||||
setUser(result);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let cancelled = false;
|
||||||
|
sessionApi
|
||||||
async function init() {
|
.refresh()
|
||||||
try {
|
.then((result) => {
|
||||||
const result = await sessionApi.refresh();
|
if (!cancelled) {
|
||||||
if (mounted) {
|
setUser(result.user);
|
||||||
updateSession(result);
|
setAccessToken(result.accessToken);
|
||||||
}
|
}
|
||||||
} catch {
|
})
|
||||||
// No valid session
|
.catch(() => {})
|
||||||
} finally {
|
.finally(() => {
|
||||||
if (mounted) {
|
if (!cancelled) setIsLoading(false);
|
||||||
setIsLoading(false);
|
});
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [updateSession]);
|
}, []);
|
||||||
|
|
||||||
if (!initialized && isLoading) {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const result = await sessionApi.login(email, password);
|
||||||
|
setUser(result.user);
|
||||||
|
setAccessToken(result.accessToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (email: string, password: string, name?: string) => {
|
||||||
|
const result = await sessionApi.register(email, password, name);
|
||||||
|
setUser(result.user);
|
||||||
|
setAccessToken(result.accessToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await sessionApi.logout();
|
||||||
|
setUser(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (data: { name?: string }) => {
|
||||||
|
const updated = await sessionApi.updateProfile(data);
|
||||||
|
setUser(updated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <div>Загрузка...</div>;
|
return <div>Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +61,7 @@ export function TestSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
updateProfile: updateProfileFn,
|
updateProfile,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
|||||||
1310
docs/features/fsd-frontend-refactor/plan.md
Normal file
1310
docs/features/fsd-frontend-refactor/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
96
docs/features/fsd-frontend-refactor/spec.md
Normal file
96
docs/features/fsd-frontend-refactor/spec.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# FSD Frontend Refactor
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Довести фронтенд-архитектуру до эталонного Feature-Sliced Design (FSD) — устранить существующие нарушения, вынести жирные страницы в виджеты/фичи, внедрить автоматический контроль FSD-правил.
|
||||||
|
|
||||||
|
Текущее состояние: 4.2/5 по FSD-зрелости. Цель: 5/5.
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
### 1. Исправить нарушение слоёв в shared/lib/test
|
||||||
|
|
||||||
|
- `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`
|
||||||
|
- Это единственное нарушение FSD (shared → app)
|
||||||
|
- Решение: вынести `TestSessionProvider` в `shared/lib/test/` и переключить `test-utils.tsx` на него
|
||||||
|
- Тесты не должны сломаться: `TestSessionProvider` предоставляет те же значения контекста (замоканые)
|
||||||
|
|
||||||
|
### 2. Вынести BrokerPositionTable из страницы в виджет
|
||||||
|
|
||||||
|
- `pages/broker-positions/ui/BrokerPositionsPage.tsx` (310 строк) содержит внутренние компоненты:
|
||||||
|
- `BrokerPositionTable` (~190 строк) — таблица с пагинацией, skeleton, inline-стилями
|
||||||
|
- `PositionTicker` (~20 строк) — ссылка на инструмент
|
||||||
|
- `formatQuantity` — хелпер
|
||||||
|
- Решение: создать `widgets/broker-positions-table/` с:
|
||||||
|
- `ui/BrokerPositionTable.tsx`
|
||||||
|
- `ui/PositionTicker.tsx`
|
||||||
|
- `index.ts`
|
||||||
|
- Страница сокращается до ~100 строк (только cursor-логика + композиция)
|
||||||
|
|
||||||
|
### 3. Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет
|
||||||
|
|
||||||
|
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx` (160 строк) содержит:
|
||||||
|
- `BrokerSummary` — сводка портфеля (карточки стоимости и денег)
|
||||||
|
- `BrokerAssetCards` — карточки классов активов (акции/облигации)
|
||||||
|
- `BrokerOverviewSkeleton` — скелетон загрузки
|
||||||
|
- `allocationPercent`, `formatAllocationPercent` — хелперы
|
||||||
|
- Решение: создать `widgets/broker-overview/` с:
|
||||||
|
- `ui/BrokerSummary.tsx`
|
||||||
|
- `ui/BrokerAssetCards.tsx`
|
||||||
|
- `ui/BrokerOverviewSkeleton.tsx`
|
||||||
|
- `index.ts`
|
||||||
|
- Страница сокращается до ~40 строк (только loading/error guard + композиция)
|
||||||
|
|
||||||
|
### 4. Вынести AddPositionForm в фичу
|
||||||
|
|
||||||
|
- `pages/portfolios/ui/PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции:
|
||||||
|
- 4 state-переменные (newSecid, newQty, newPrice, newDate)
|
||||||
|
- 4 input-поля с inline-стилями
|
||||||
|
- Валидация и submit
|
||||||
|
- Это полноценная бизнес-фича, не место в странице
|
||||||
|
- Решение: создать `features/add-position/` с:
|
||||||
|
- `api/useAddPosition.ts` — мутация (обёртка над usePositionMutations)
|
||||||
|
- `model/useAddPositionForm.ts` — управление формой
|
||||||
|
- `ui/AddPositionForm.tsx` — UI формы
|
||||||
|
- `index.ts`
|
||||||
|
- Страница сокращается до ~140 строк (showAddForm toggle + `<AddPositionForm portfolioId={id} />`)
|
||||||
|
|
||||||
|
### 5. Вынести cursor-пагинацию в shared-хук
|
||||||
|
|
||||||
|
- 25 строк cursor-логики дублируются в:
|
||||||
|
- `pages/broker-positions/ui/BrokerPositionsPage.tsx`
|
||||||
|
- `pages/broker-operations/ui/BrokerOperationsPage.tsx`
|
||||||
|
- Решение: создать `shared/lib/useCursorPagination.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function useCursorPagination() {
|
||||||
|
// cursor, pageNumber, handleNext(cursor), handlePrevious, reset
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Добавить ESLint-плагин FSD
|
||||||
|
|
||||||
|
- Текущий `import/no-restricted-paths` проверяет только межслойные границы
|
||||||
|
- Плагин `@conarti/eslint-plugin-feature-sliced` добавит:
|
||||||
|
- Проверки public API (запрет импорта из внутренних модулей в обход index.ts)
|
||||||
|
- Проверки сегментов (api/ui/model не импортируют друг друга напрямую)
|
||||||
|
- Решение: установить плагин и включить recommended rules
|
||||||
|
|
||||||
|
## Критерии приёмки (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] AC1: `test-utils.tsx` не импортирует из `@/app/*`
|
||||||
|
- [ ] AC2: `widgets/broker-positions-table` создан, страница использует его
|
||||||
|
- [ ] AC3: `widgets/broker-overview` создан, страница использует его
|
||||||
|
- [ ] AC4: `features/add-position` создана, страница использует её
|
||||||
|
- [ ] AC5: `shared/lib/useCursorPagination.ts` создан, обе страницы используют его
|
||||||
|
- [ ] AC6: `@conarti/eslint-plugin-feature-sliced` установлен и настроен
|
||||||
|
- [ ] AC7: `npm run lint` проходит без ошибок
|
||||||
|
- [ ] AC8: `npm run build` проходит без ошибок
|
||||||
|
- [ ] AC9: Все существующие тесты проходят (включая тесты рефакторимых компонентов)
|
||||||
|
- [ ] AC10: Визуально поведение страниц не изменилось
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Не менять API-контракты компонентов (пропсы, типы). Только перемещение кода.
|
||||||
|
- Не добавлять новую функциональность — только рефакторинг.
|
||||||
|
- inline-стили переносятся вместе с компонентами; централизация UI-кита — отдельная задача.
|
||||||
13
docs/features/fsd-frontend-refactor/tasks.md
Normal file
13
docs/features/fsd-frontend-refactor/tasks.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# FSD Frontend Refactor — Tasks
|
||||||
|
|
||||||
|
**Ветка:** `codex/fsd-frontend-refactor`
|
||||||
|
|
||||||
|
**Порядок:** последовательно, каждый task — независимый коммит.
|
||||||
|
|
||||||
|
- [ ] **Task 1:** Исправить нарушение shared → app — TestSessionProvider
|
||||||
|
- [ ] **Task 2:** Вынести BrokerPositionTable из страницы в widgets
|
||||||
|
- [ ] **Task 3:** Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в widgets
|
||||||
|
- [ ] **Task 4:** Вынести AddPositionForm в features/add-position
|
||||||
|
- [ ] **Task 5:** Вынести useCursorPagination в shared/lib/
|
||||||
|
- [ ] **Task 6:** Добавить @conarti/eslint-plugin-feature-sliced
|
||||||
|
- [ ] **Финальная проверка:** `npm run build -w apps/frontend && npm run lint -w apps/frontend && npm run -w apps/frontend test run`
|
||||||
Loading…
x
Reference in New Issue
Block a user