fix: move test SessionProvider and SessionContext to shared layer for FSD compliance
This commit is contained in:
parent
79011c779f
commit
943a6aec2d
@ -1,15 +1 @@
|
||||
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);
|
||||
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { SessionContext, type SessionState } from './sessionContext';
|
||||
import { SessionContext } from './sessionContext';
|
||||
import { useSession } from './useSession';
|
||||
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 = {
|
||||
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
||||
accessToken: 'mock-access-token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn(),
|
||||
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 { SessionContext, type SessionContextValue } from '@/entities/session';
|
||||
import * as sessionApi from '@/entities/session';
|
||||
import type { UserResponse } from '@/shared/api/responses';
|
||||
import { type ReactNode, useEffect, useState, useCallback } from 'react';
|
||||
import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||
import * as sessionApi from '@/entities/session/api/sessionApi';
|
||||
|
||||
export function TestSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null);
|
||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<SessionContextValue['user']>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
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(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const result = await sessionApi.refresh();
|
||||
if (mounted) {
|
||||
updateSession(result);
|
||||
let cancelled = false;
|
||||
sessionApi
|
||||
.refresh()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
}
|
||||
} catch {
|
||||
// No valid session
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setInitialized(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -87,7 +61,7 @@ export function TestSessionProvider({ children }: { children: ReactNode }) {
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile: updateProfileFn,
|
||||
updateProfile,
|
||||
};
|
||||
|
||||
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