From 943a6aec2d9ebe0f865f783cf5610340ad7edbe2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 23:09:15 +0300 Subject: [PATCH] fix: move test SessionProvider and SessionContext to shared layer for FSD compliance --- .../entities/session/model/sessionContext.ts | 16 +- .../session/model/useSession.test.tsx | 15 +- .../src/shared/lib/session-context.ts | 15 + .../shared/lib/test/TestSessionProvider.tsx | 114 +- docs/features/fsd-frontend-refactor/plan.md | 1310 +++++++++++++++++ docs/features/fsd-frontend-refactor/spec.md | 96 ++ docs/features/fsd-frontend-refactor/tasks.md | 13 + 7 files changed, 1493 insertions(+), 86 deletions(-) create mode 100644 apps/frontend/src/shared/lib/session-context.ts create mode 100644 docs/features/fsd-frontend-refactor/plan.md create mode 100644 docs/features/fsd-frontend-refactor/spec.md create mode 100644 docs/features/fsd-frontend-refactor/tasks.md diff --git a/apps/frontend/src/entities/session/model/sessionContext.ts b/apps/frontend/src/entities/session/model/sessionContext.ts index 36ef2b9..eddf339 100644 --- a/apps/frontend/src/entities/session/model/sessionContext.ts +++ b/apps/frontend/src/entities/session/model/sessionContext.ts @@ -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; - register: (email: string, password: string, name?: string) => Promise; - logout: () => Promise; - updateProfile: (data: { name?: string }) => Promise; -} - -export const SessionContext = createContext(null); +export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context'; diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx index a289dae..204b815 100644 --- a/apps/frontend/src/entities/session/model/useSession.test.tsx +++ b/apps/frontend/src/entities/session/model/useSession.test.tsx @@ -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; + logout: () => Promise; + register: () => Promise; + updateProfile: () => Promise; + refreshSession: () => Promise; +}; + 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), diff --git a/apps/frontend/src/shared/lib/session-context.ts b/apps/frontend/src/shared/lib/session-context.ts new file mode 100644 index 0000000..36ef2b9 --- /dev/null +++ b/apps/frontend/src/shared/lib/session-context.ts @@ -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; + register: (email: string, password: string, name?: string) => Promise; + logout: () => Promise; + updateProfile: (data: { name?: string }) => Promise; +} + +export const SessionContext = createContext(null); diff --git a/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx index fdb35d5..f13b5ef 100644 --- a/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx +++ b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx @@ -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(null); - const [accessToken, setAccessTokenState] = useState(null); + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(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
Загрузка...
; } @@ -87,7 +61,7 @@ export function TestSessionProvider({ children }: { children: ReactNode }) { login, register, logout, - updateProfile: updateProfileFn, + updateProfile, }; return {children}; diff --git a/docs/features/fsd-frontend-refactor/plan.md b/docs/features/fsd-frontend-refactor/plan.md new file mode 100644 index 0000000..0955199 --- /dev/null +++ b/docs/features/fsd-frontend-refactor/plan.md @@ -0,0 +1,1310 @@ +# FSD Frontend Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Довести FSD-зрелость фронтенда до 5/5: устранить нарушение слоёв, вынести жирные страницы в виджеты/фичи, DRY cursor-пагинацию, добавить FSD ESLint. + +**Architecture:** 6 независимых задач-рефакторинга. Каждая — перемещение существующего кода в правильный FSD-слой без изменения поведения. После каждой задачи — `npm run build` и `npm run -w apps/frontend test run`. + +**Tech Stack:** React 18, TypeScript, TanStack Query v5, Vitest, @conarti/eslint-plugin-feature-sliced + +--- + +### File Map + +``` +apps/frontend/src/ +├── features/ +│ └── add-position/ # NEW +│ ├── index.ts # barrel +│ ├── api/useAddPosition.ts # usePositionMutations wrapper +│ ├── model/useAddPositionForm.ts # form state management +│ └── ui/AddPositionForm.tsx # extracted from PortfolioDetailPage +├── widgets/ +│ ├── broker-overview/ # NEW +│ │ ├── index.ts # barrel +│ │ ├── ui/BrokerSummary.tsx # extracted from BrokerAccountOverviewPage +│ │ ├── ui/BrokerAssetCards.tsx # extracted from BrokerAccountOverviewPage +│ │ └── ui/BrokerOverviewSkeleton.tsx # extracted from BrokerAccountOverviewPage +│ └── broker-positions-table/ # NEW +│ ├── index.ts # barrel +│ ├── ui/BrokerPositionTable.tsx # extracted from BrokerPositionsPage +│ ├── ui/PositionTicker.tsx # extracted from BrokerPositionsPage +├── shared/ +│ ├── lib/ +│ │ ├── useCursorPagination.ts # NEW — DRY hook +│ │ └── test/ +│ │ ├── TestSessionProvider.tsx # NEW — fixes layer violation +│ │ └── test-utils.tsx # MODIFY — use TestSessionProvider +├── pages/ +│ ├── broker-positions/ui/BrokerPositionsPage.tsx # MODIFY — use widgets + hook +│ ├── broker-operations/ui/BrokerOperationsPage.tsx # MODIFY — use hook +│ ├── broker-account/ui/BrokerAccountOverviewPage.tsx # MODIFY — use widgets +│ └── portfolios/ui/PortfolioDetailPage.tsx # MODIFY — use features/add-position +└── .eslintrc.cjs # MODIFY — add FSD plugin +``` + +--- + +### Task 1: Исправить нарушение shared → app (TestSessionProvider) + +**Текущая проблема:** `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`. По FSD shared не может импортировать из app. + +**Решение:** Создать `TestSessionProvider` в shared/lib/test/ и переключить test-utils на него. + +**Files:** +- Create: `apps/frontend/src/shared/lib/test/TestSessionProvider.tsx` +- Modify: `apps/frontend/src/shared/lib/test/test-utils.tsx` + +- [ ] **Step 1: Создать TestSessionProvider** + +`apps/frontend/src/shared/lib/test/TestSessionProvider.tsx`: +```tsx +import { type ReactNode } from 'react'; +import { SessionContext } from '@/entities/session'; + +function noop() { + return Promise.resolve(); +} + +export function TestSessionProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +- [ ] **Step 2: Заменить SessionProvider на TestSessionProvider в test-utils.tsx** + +В `apps/frontend/src/shared/lib/test/test-utils.tsx`: +```diff +- import { SessionProvider } from '@/app/providers'; ++ import { TestSessionProvider } from './TestSessionProvider'; +``` +```diff +- {children} ++ {children} +``` + +- [ ] **Step 3: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +Expected: build passes, all tests green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/shared/lib/test/ +git commit -m "fix: move test SessionProvider to shared layer for FSD compliance" +``` + +--- + +### Task 2: Вынести BrokerPositionTable из страницы в виджет + +**Текущая проблема:** `BrokerPositionsPage.tsx` (310 строк) содержит `BrokerPositionTable` (~190 строк) и `PositionTicker` (~20 строк). + +**Решение:** Вынести в `widgets/broker-positions-table/`, страница остаётся только с cursor-логикой. + +**Files:** +- Create: `apps/frontend/src/widgets/broker-positions-table/index.ts` +- Create: `apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx` +- Create: `apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx` +- Modify: `apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx` + +- [ ] **Step 1: Создать PositionTicker** + +`apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerPosition } from '@/shared/api/responses'; +import { getBrokerInstrumentPath } from '@/entities/broker-position'; + +export function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} +``` + +- [ ] **Step 2: Создать BrokerPositionTable** + +`apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx`: +```tsx +import type { BrokerPositionsPage } from '@/shared/api/responses'; +import { TableSkeleton } from '@/shared/ui/TableSkeleton'; +import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; +import { PositionTicker } from './PositionTicker'; + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const pagButtonStyle = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +} satisfies React.CSSProperties; + +const pagButtonDisabledStyle = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +} satisfies React.CSSProperties; + +type BrokerPositionTableProps = { + title: string; + page: BrokerPositionsPage | undefined; + isLoading: boolean; + isFetching: boolean; + emptyMessage: string; + pageNumber: number; + onNext: () => void; + onPrevious: () => void; +}; + +export function BrokerPositionTable({ + title, + page, + isLoading, + isFetching, + emptyMessage, + pageNumber, + onNext, + onPrevious, +}: BrokerPositionTableProps) { + const positions = page?.items ?? []; + const canGoBack = pageNumber > 1; + const canGoForward = Boolean(page?.hasNext && page.nextCursor); + + return ( +
+
+

+ {title} +

+
+ + + {pageNumber} + + +
+
+ + {isLoading ? ( +
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+ ) : positions.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
ТикерНазваниеКоличествоЦенаСтоимость
+ + {position.name || '-'} + + {formatQuantity(position.quantity)}{formatMoney(position.currentPrice)}{formatMoney(position.currentValue)}
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Создать barrel** + +`apps/frontend/src/widgets/broker-positions-table/index.ts`: +```ts +export { BrokerPositionTable } from './ui/BrokerPositionTable'; +``` + +- [ ] **Step 4: Обновить страницу BrokerPositionsPage** + +`apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx`: +```tsx +import { useState } from 'react'; +import { useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerPositionTable } from '@/widgets/broker-positions-table'; + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor }); + + function handleNext() { + const nextCursor = positions.data?.nextCursor; + if (!nextCursor || !positions.data?.hasNext) return; + setCursorStack((previous) => [...previous, cursor]); + setCursor(nextCursor); + } + + function handlePrevious() { + if (cursorStack.length === 0) return; + setCursor(cursorStack[cursorStack.length - 1]); + setCursorStack((previous) => previous.slice(0, -1)); + } + + if (positions.error) { + return ( +
+

{title}

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + + ); +} +``` + +- [ ] **Step 5: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/src/widgets/broker-positions-table/ apps/frontend/src/pages/broker-positions/ +git commit -m "refactor: extract BrokerPositionTable to widgets layer" +``` + +--- + +### Task 3: Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет + +**Текущая проблема:** `BrokerAccountOverviewPage.tsx` (160 строк) содержит 3 внутренних компонента + 2 хелпера. + +**Решение:** Создать `widgets/broker-overview/` с тремя компонентами. + +**Files:** +- Create: `apps/frontend/src/widgets/broker-overview/index.ts` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx` +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` + +- [ ] **Step 1: Создать BrokerSummary** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx`: +```tsx +import type { BrokerPortfolio } from '@/shared/api/responses'; +import { + formatBrokerMoney as formatMoney, + formatBrokerPercent as formatPercent, +} from '@/shared/lib/formatters'; + +export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { + return ( +
+
+ Стоимость портфеля + + {formatMoney(portfolio.totals.portfolio)} + + За день: {formatMoney(portfolio.yields.daily)} + Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} + Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} +
+
+ Денежный остаток + {portfolio.cash.length === 0 ? ( + Нет денежных остатков + ) : ( +
    + {portfolio.cash.map((money, index) => ( +
  • + {money.currency} + {formatMoney(money)} +
  • + ))} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Создать BrokerAssetCards** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; +import { formatBrokerMoney as formatMoney, pluralize } from '@/shared/lib/formatters'; + +function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { + if (!value || !total || total.value <= 0) return null; + return (value.value / total.value) * 100; +} + +function formatAllocationPercent(value: number | null) { + return value === null ? '—' : `${value.toFixed(1)}%`; +} + +export function BrokerAssetCards({ + accountId, + portfolio, +}: { + accountId: string; + portfolio: BrokerPortfolio; +}) { + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const cards = [ + { + label: 'Акции', + count: portfolio.positionCounts.shares, + countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), + value: portfolio.totals.shares, + path: `${basePath}/shares`, + }, + { + label: 'Облигации', + count: portfolio.positionCounts.bonds, + countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), + value: portfolio.totals.bonds, + path: `${basePath}/bonds`, + }, + ]; + + return ( +
+ {cards.map((card) => ( + + {card.label} + {card.count} {card.countLabel} + {formatMoney(card.value)} + {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} + + ))} +
+ ); +} +``` + +- [ ] **Step 3: Создать BrokerOverviewSkeleton** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx`: +```tsx +import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; + +export function BrokerOverviewSkeleton() { + return ( +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Создать barrel** + +`apps/frontend/src/widgets/broker-overview/index.ts`: +```ts +export { BrokerSummary } from './ui/BrokerSummary'; +export { BrokerAssetCards } from './ui/BrokerAssetCards'; +export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton'; +``` + +- [ ] **Step 5: Обновить BrokerAccountOverviewPage** + +`apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import { useBrokerOperations } from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview'; + +export function BrokerAccountOverviewPage() { + const { accountId, portfolio } = useBrokerAccountContext(); + const operations = useBrokerOperations(accountId, { limit: 5 }); + + if (portfolio.isLoading) return ; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить сводку счёта

; + } + + return ( +
+ + + + {operations.error ? ( +

Не удалось загрузить последние операции

+ ) : ( + Вся история + } + emptyMessage="Операций с начала текущего года нет" + isLoading={operations.isLoading} + isFetching={operations.isFetching} + page={operations.data} + /> + )} +
+ ); +} +``` + +- [ ] **Step 6: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/widgets/broker-overview/ apps/frontend/src/pages/broker-account/ +git commit -m "refactor: extract BrokerOverview components to widgets layer" +``` + +--- + +### Task 4: Вынести AddPositionForm в features/add-position + +**Текущая проблема:** `PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции с 4 state-переменными. + +**Решение:** Создать `features/add-position/` с хуком формы и UI-компонентом. + +**Files:** +- Create: `apps/frontend/src/features/add-position/api/useAddPosition.ts` +- Create: `apps/frontend/src/features/add-position/model/useAddPositionForm.ts` +- Create: `apps/frontend/src/features/add-position/ui/AddPositionForm.tsx` +- Create: `apps/frontend/src/features/add-position/index.ts` +- Modify: `apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx` + +- [ ] **Step 1: Создать useAddPosition (api-слой)** + +`apps/frontend/src/features/add-position/api/useAddPosition.ts`: +```ts +import { usePositionMutations } from '@/entities/portfolio'; + +export function useAddPosition(portfolioId: number) { + const { add } = usePositionMutations(portfolioId); + return add; +} +``` + +- [ ] **Step 2: Создать useAddPositionForm (model-слой)** + +`apps/frontend/src/features/add-position/model/useAddPositionForm.ts`: +```ts +import { useState } from 'react'; + +export function useAddPositionForm() { + const [showAddForm, setShowAddForm] = useState(false); + const [newSecid, setNewSecid] = useState(''); + const [newQty, setNewQty] = useState('1'); + const [newPrice, setNewPrice] = useState(''); + const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); + + function reset() { + setNewSecid(''); + setNewQty('1'); + setNewPrice(''); + setNewDate(new Date().toISOString().split('T')[0]); + } + + return { + showAddForm, + setShowAddForm, + newSecid, + setNewSecid, + newQty, + setNewQty, + newPrice, + setNewPrice, + newDate, + setNewDate, + reset, + }; +} +``` + +- [ ] **Step 3: Создать AddPositionForm (ui-слой)** + +`apps/frontend/src/features/add-position/ui/AddPositionForm.tsx`: +```tsx +import { useAddPosition } from '../api/useAddPosition'; +import { useAddPositionForm } from '../model/useAddPositionForm'; + +const inputStyle: React.CSSProperties = { + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, +}; + +export function AddPositionForm({ portfolioId }: { portfolioId: number }) { + const addPosition = useAddPosition(portfolioId); + const form = useAddPositionForm(); + + function handleAddPosition() { + if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return; + addPosition.mutate( + { + secid: form.newSecid.trim().toUpperCase(), + quantity: parseInt(form.newQty, 10), + buyPrice: form.newPrice ? parseFloat(form.newPrice) : undefined, + buyDate: form.newDate || undefined, + }, + { + onSuccess: () => { + form.setShowAddForm(false); + form.reset(); + }, + }, + ); + } + + return ( +
+
+ + form.setNewSecid(e.target.value)} + placeholder="SBER" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewQty(e.target.value)} + style={{ ...inputStyle, width: 100 }} + /> +
+
+ + form.setNewPrice(e.target.value)} + placeholder="0.00" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewDate(e.target.value)} + style={{ ...inputStyle, width: 150 }} + /> +
+ +
+ ); +} +``` + +- [ ] **Step 4: Создать barrel** + +`apps/frontend/src/features/add-position/index.ts`: +```ts +export { AddPositionForm } from './ui/AddPositionForm'; +``` + +- [ ] **Step 5: Обновить PortfolioDetailPage** + +`apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx`: +```tsx +import { useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { usePortfolio, usePortfolioMutations } from '@/entities/portfolio'; +import { PortfolioForm } from '@/widgets/portfolio-form'; +import { PortfolioSummary } from '@/widgets/portfolio-summary'; +import { AnalyticsSummary } from '@/widgets/portfolio-analytics'; +import { SharePositionTable } from '@/widgets/share-positions-table'; +import { BondPositionTable } from '@/widgets/bond-positions-table'; +import { AddPositionForm } from '@/features/add-position'; + +export function PortfolioDetailPage() { + const { id } = useParams<{ id: string }>(); + const portfolioId = parseInt(id!, 10); + + const { data: portfolio, isLoading, error } = usePortfolio(portfolioId); + const { update, remove } = usePortfolioMutations(); + const [editing, setEditing] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + if (isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + if (error || !portfolio) { + return ( +
+ Ошибка загрузки портфеля +
+ ); + } + + async function handleDelete() { + if (window.confirm('Удалить портфель и все позиции?')) { + remove.mutate(portfolioId); + } + } + + return ( +
+
+ + ← К списку + +

{portfolio.name}

+ + +
+ + {editing && ( +
+

+ Редактировать портфель +

+ update.mutate({ id: portfolioId, data: d })} + onCancel={() => setEditing(false)} + isLoading={update.isPending} + /> +
+ )} + + + {portfolio.analytics && } + +
+

Позиции

+ +
+ + {showAddForm && } + + p.type === 'share')} + onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })} + onDeletePosition={(positionId) => { + if (window.confirm('Удалить позицию?')) remove.mutate(positionId); + }} + /> + p.type === 'bond')} + onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })} + onDeletePosition={(positionId) => { + if (window.confirm('Удалить позицию?')) remove.mutate(positionId); + }} + /> +
+ ); +} +``` + +- [ ] **Step 6: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/features/add-position/ apps/frontend/src/pages/portfolios/ +git commit -m "refactor: extract AddPositionForm to features layer" +``` + +--- + +### Task 5: Вынести cursor-пагинацию в shared/lib/useCursorPagination + +**Текущая проблема:** 25 строк cursor-логики дублируются в `BrokerPositionsPage` и `BrokerOperationsPage`. + +**Решение:** Создать `shared/lib/useCursorPagination.ts` и использовать в обеих страницах. + +**Files:** +- Create: `apps/frontend/src/shared/lib/useCursorPagination.ts` +- Modify: `apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx` +- Modify: `apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx` + +- [ ] **Step 1: Создать хук** + +`apps/frontend/src/shared/lib/useCursorPagination.ts`: +```ts +import { useState, useCallback } from 'react'; + +export function useCursorPagination() { + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + + const handleNext = useCallback((nextCursor: string | undefined) => { + if (!nextCursor) return; + setCursorStack((prev) => [...prev, cursor]); + setCursor(nextCursor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cursor]); + + const handlePrevious = useCallback(() => { + setCursorStack((prev) => { + if (prev.length === 0) return prev; + setCursor(prev[prev.length - 1]); + return prev.slice(0, -1); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const reset = useCallback(() => { + setCursor(undefined); + setCursorStack([]); + }, []); + + return { + cursor, + pageNumber: cursorStack.length + 1, + handleNext, + handlePrevious, + reset, + }; +} +``` + +- [ ] **Step 2: Обновить BrokerPositionsPage** + +`apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx`: +```tsx +import { useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerPositionTable } from '@/widgets/broker-positions-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const pagination = useCursorPagination(); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor }); + + if (positions.error) { + return ( +
+

{title}

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + pagination.handleNext(positions.data?.nextCursor)} + onPrevious={pagination.handlePrevious} + /> + ); +} +``` + +- [ ] **Step 3: Обновить BrokerOperationsPage** + +`apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx`: +```tsx +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + BROKER_OPERATION_TYPE_OPTIONS, + isBrokerOperationType, + useBrokerOperations, +} from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; + +export function BrokerOperationsPage() { + const { accountId } = useBrokerAccountContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const urlType = searchParams.get('type'); + const selectedType = isBrokerOperationType(urlType) ? urlType : ''; + const pagination = useCursorPagination(); + + const operations = useBrokerOperations(accountId, { + limit: 10, + cursor: pagination.cursor, + operationTypes: selectedType || undefined, + }); + + useEffect(() => { + pagination.reset(); + }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps + + function handleTypeChange(event: React.ChangeEvent) { + const nextType = event.target.value; + setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); + } + + const history = operations.error ? ( +

Не удалось загрузить историю операций

+ ) : ( + 1, + canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), + onPrevious: pagination.handlePrevious, + onNext: () => pagination.handleNext(operations.data?.nextCursor), + }} + /> + ); + + return ( +
+
+

+ Операции +

+ +
+ {history} +
+ ); +} +``` + +- [ ] **Step 4: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/shared/lib/useCursorPagination.ts apps/frontend/src/pages/broker-positions/ apps/frontend/src/pages/broker-operations/ +git commit -m "refactor: extract useCursorPagination to shared layer" +``` + +--- + +### Task 6: Добавить @conarti/eslint-plugin-feature-sliced + +**Текущая проблема:** ESLint проверяет только межслойные границы через `import/no-restricted-paths`. Нет проверок public API и сегментов. + +**Решение:** Установить плагин и включить recommended rules. + +**Files:** +- Modify: `apps/frontend/.eslintrc.cjs` +- Modify: `apps/frontend/package.json` (через npm install) + +- [ ] **Step 1: Установить плагин** + +```bash +npm install -w apps/frontend --save-dev @conarti/eslint-plugin-feature-sliced +``` + +- [ ] **Step 2: Обновить .eslintrc.cjs** + +```diff + plugins: [ + '@typescript-eslint/eslint-plugin', + 'react', + 'react-hooks', + 'import', ++ '@conarti/feature-sliced', + ], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', ++ 'plugin:@conarti/feature-sliced/rules/recommended', + ], +``` + +- [ ] **Step 3: Проверить lint** + +```bash +npm run lint -w apps/frontend 2>&1 +``` + +Expected: 0 errors (если появятся ложные срабатывания — см. Step 4). + +- [ ] **Step 4 (если нужно): Ослабить правила** + +Если `plugin:@conarti/feature-sliced/rules/recommended` даёт ложные срабатывания (например, на импорты типов из соседних модулей), ослабить конкретные правила: +```js +rules: { + // existing rules... + '@conarti/feature-sliced/public-api': 'warn', + '@conarti/feature-sliced/absolute-relative': 'warn', +} +``` + +- [ ] **Step 5: Проверить сборку** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/.eslintrc.cjs apps/frontend/package.json apps/frontend/package-lock.json +git commit -m "chore: add @conarti/eslint-plugin-feature-sliced for FSD rule enforcement" +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage:** Все 6 AC из spec.md имеют соответствующие задачи (Task 1→AC1, Task 2→AC2, Task 3→AC3, Task 4→AC4, Task 5→AC5, Task 6→AC6). AC7-AC10 проверяются в каждом task. +- [x] **Placeholder scan:** Нет TBD, TODO, или незаполненных шагов. Каждый шаг содержит полный код. +- [x] **Type consistency:** Все импорты и типы соответствуют существующему коду (formatBrokerMoney, BrokerPositionsPage, BrokerOperationsTable props, SessionContextValue). +- [x] **No circular deps:** Создаваемые файлы импортируют только из shared, entities или друг друга — без циклических зависимостей. diff --git a/docs/features/fsd-frontend-refactor/spec.md b/docs/features/fsd-frontend-refactor/spec.md new file mode 100644 index 0000000..252a01f --- /dev/null +++ b/docs/features/fsd-frontend-refactor/spec.md @@ -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 + ``) + +### 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-кита — отдельная задача. diff --git a/docs/features/fsd-frontend-refactor/tasks.md b/docs/features/fsd-frontend-refactor/tasks.md new file mode 100644 index 0000000..4dfde8d --- /dev/null +++ b/docs/features/fsd-frontend-refactor/tasks.md @@ -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`