Compare commits
No commits in common. "1f426e973426586672237eb62755ae94d2547b7b" and "055bc230971066694bdfd6af7be4eeffe18d922c" have entirely different histories.
1f426e9734
...
055bc23097
1
apps/frontend/src/App.tsx
Normal file
1
apps/frontend/src/App.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './app/App';
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { SearchBar } from '@/widgets/search-bar';
|
import { SearchBar } from '@/widgets/search-bar';
|
||||||
import { useSession } from '@/entities/session';
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { isAuthenticated, user, logout } = useSession();
|
const { isAuthenticated, user, logout } = useSession();
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useContext } from 'react';
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../test/server';
|
||||||
import { SessionContext } from '@/entities/session';
|
import { SessionContext } from '@/entities/session/model/sessionContext';
|
||||||
import { SessionProvider } from './SessionProvider';
|
import { SessionProvider } from './SessionProvider';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import * as sessionApi from '@/entities/session';
|
import * as sessionApi from '@/entities/session/api/sessionApi';
|
||||||
import { SessionContext, type SessionContextValue } from '@/entities/session';
|
import { SessionContext, type SessionContextValue } from '@/entities/session/model/sessionContext';
|
||||||
import { configureAuth } from '@/shared/api/client';
|
import { setOnUnauthorized } from '@/shared/api/client';
|
||||||
import {
|
|
||||||
setOnUnauthorized,
|
|
||||||
getAccessToken,
|
|
||||||
handleUnauthorized,
|
|
||||||
} from '@/entities/session/api/tokenManager';
|
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
import type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
@ -82,12 +77,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [updateSession]);
|
}, [updateSession]);
|
||||||
|
|
||||||
// Wire up auth config and auto-logout on unauthorized
|
// Set up auto-logout on unauthorized
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configureAuth({
|
|
||||||
getAccessToken,
|
|
||||||
handleUnauthorized,
|
|
||||||
});
|
|
||||||
setOnUnauthorized(() => {
|
setOnUnauthorized(() => {
|
||||||
clearSession();
|
clearSession();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { ProfilePage } from '@/pages/profile';
|
|||||||
import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios';
|
import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios';
|
||||||
import { ScreenerPage } from '@/pages/screener';
|
import { ScreenerPage } from '@/pages/screener';
|
||||||
import { BrokerAccountsPage } from '@/pages/broker-accounts';
|
import { BrokerAccountsPage } from '@/pages/broker-accounts';
|
||||||
import { BrokerAccountLayout } from '@/widgets/broker-account-layout';
|
import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout';
|
||||||
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
|
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
|
||||||
import { BrokerPositionsPage } from '@/pages/broker-positions';
|
import { BrokerPositionsPage } from '@/pages/broker-positions';
|
||||||
import { BrokerOperationsPage } from '@/pages/broker-operations';
|
import { BrokerOperationsPage } from '@/pages/broker-operations';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useSession } from '@/entities/session';
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useBondCandles } from './useBondCandles';
|
import { useBondCandles } from './useBondCandles';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
export { useBrokerAccounts } from './model/useBrokerAccounts';
|
export { useBrokerAccounts } from './model/useBrokerAccounts';
|
||||||
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
|
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
|
||||||
export { useBrokerPortfolio } from './model/useBrokerPortfolio';
|
export { useBrokerPortfolio } from './model/useBrokerPortfolio';
|
||||||
export {
|
export { aggregateBrokerAccounts } from './model/brokerAccountsOverview';
|
||||||
aggregateBrokerAccounts,
|
|
||||||
type BrokerAccountsAggregate,
|
|
||||||
} from './model/brokerAccountsOverview';
|
|
||||||
export {
|
export {
|
||||||
getBrokerAccounts,
|
getBrokerAccounts,
|
||||||
getBrokerPortfolio,
|
getBrokerPortfolio,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||||
import { useBrokerPortfolio } from '@/entities/broker-account';
|
import { useBrokerPortfolio } from '../model/useBrokerPortfolio';
|
||||||
|
|
||||||
export type BrokerAccountContext = {
|
export type BrokerAccountContext = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -4,8 +4,8 @@ export {
|
|||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
} from '@/entities/broker-operation';
|
} from '../../broker-operation/model/operationFilters';
|
||||||
export type { BrokerOperationImpact } from '@/entities/broker-operation';
|
export type { BrokerOperationImpact } from '../../broker-operation/model/operationFilters';
|
||||||
|
|
||||||
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { request } from '@/shared/api/client';
|
|
||||||
import type { SearchResultItem } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
|
|
||||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
|
||||||
q,
|
|
||||||
type,
|
|
||||||
limit: String(limit),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,2 +1 @@
|
|||||||
export { useSearch } from './model/useSearch';
|
export { useSearch } from './model/useSearch';
|
||||||
export { searchSecurities } from './api/searchApi';
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '@/test/server';
|
||||||
import { useSearch } from '@/entities/search';
|
import { useSearch } from '@/entities/search';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { searchSecurities } from '../api/searchApi';
|
import { searchSecurities } from '@/shared/api/client';
|
||||||
import type { SearchResultItem } from '@/shared/api/responses';
|
import type { SearchResultItem } from '@/shared/api/responses';
|
||||||
|
|
||||||
export function useSearch(query: string) {
|
export function useSearch(query: string) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../../test/server';
|
||||||
import { setAccessToken, getAccessToken } from './tokenManager';
|
import { setAccessToken, getAccessToken } from '@/shared/api/client';
|
||||||
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
|
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request, setAccessToken } from '@/shared/api/client';
|
||||||
import { setAccessToken } from './tokenManager';
|
|
||||||
import type { AuthResponse, UserResponse } from '@/shared/api/responses';
|
import type { AuthResponse, UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
import type { AuthResponse } from '@/shared/api/responses';
|
|
||||||
import { normalizeEnvelope } from '@/shared/api/client';
|
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
|
||||||
let onUnauthorized: (() => void) | null = null;
|
|
||||||
let isRefreshing = false;
|
|
||||||
let refreshPromise: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
export function setAccessToken(token: string | null) {
|
|
||||||
accessToken = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOnUnauthorized(cb: () => void) {
|
|
||||||
onUnauthorized = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokens(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) return false;
|
|
||||||
const json = await res.json();
|
|
||||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleUnauthorized(): Promise<boolean> {
|
|
||||||
if (isRefreshing && refreshPromise) {
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
refreshPromise = refreshTokens().then((success) => {
|
|
||||||
isRefreshing = false;
|
|
||||||
refreshPromise = null;
|
|
||||||
if (!success) {
|
|
||||||
accessToken = null;
|
|
||||||
onUnauthorized?.();
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
});
|
|
||||||
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
@ -43,4 +43,12 @@ describe('useSession', () => {
|
|||||||
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
expect(typeof result.current.register).toBe('function');
|
expect(typeof result.current.register).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when used without SessionProvider', () => {
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useSession(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
});
|
||||||
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useStockCandles } from './useStockCandles';
|
import { useStockCandles } from './useStockCandles';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useStockDividends } from './useStockDividends';
|
import { useStockDividends } from './useStockDividends';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { AppProviders } from './app/providers/AppProviders';
|
import { AppProviders } from './app/providers/AppProviders';
|
||||||
import App from './app/App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||||
import { useBrokerOperations } from '@/entities/broker-operation';
|
import { useBrokerOperations } from '../../../entities/broker-operation';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
|
||||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
|
import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart';
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { BrokerOperationsTable } from '../../../widgets/broker-operations-table';
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import {
|
|||||||
aggregateBrokerAccounts,
|
aggregateBrokerAccounts,
|
||||||
useBrokerAccounts,
|
useBrokerAccounts,
|
||||||
useBrokerAccountPortfolios,
|
useBrokerAccountPortfolios,
|
||||||
} from '@/entities/broker-account';
|
} from '../../../entities/broker-account';
|
||||||
import { BrokerAccountCard } from '@/widgets/broker-account-card';
|
import { BrokerAccountCard } from '../../../widgets/broker-account-card';
|
||||||
import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary';
|
import { BrokerAccountsSummary } from '../../../widgets/broker-accounts-summary';
|
||||||
|
|
||||||
function BrokerAccountsPageSkeleton() {
|
function BrokerAccountsPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import {
|
|||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
useBrokerOperations,
|
useBrokerOperations,
|
||||||
} from '@/entities/broker-operation';
|
} from '../../../entities/broker-operation';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { BrokerOperationsTable } from '../../../widgets/broker-operations-table';
|
||||||
|
|
||||||
export function BrokerOperationsPage() {
|
export function BrokerOperationsPage() {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext();
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import type {
|
|||||||
BrokerPositionsPage as BrokerPositionsPageData,
|
BrokerPositionsPage as BrokerPositionsPageData,
|
||||||
} from '@/shared/api/responses';
|
} from '@/shared/api/responses';
|
||||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||||
import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
|
||||||
|
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { screen } from '@testing-library/react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../test/server';
|
||||||
import { LoginPage } from './ui/LoginPage';
|
import { LoginPage } from './ui/LoginPage';
|
||||||
import { renderWithProviders } from '@/shared/lib/test/test-utils';
|
import { renderWithProviders } from '../../test/test-utils';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../test/server';
|
||||||
import { ProfilePage } from './ui/ProfilePage';
|
import { ProfilePage } from './ui/ProfilePage';
|
||||||
import { renderWithProviders } from '@/shared/lib/test/test-utils';
|
import { renderWithProviders } from '../../test/test-utils';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { screen } from '@testing-library/react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../test/server';
|
||||||
import { RegisterPage } from './ui/RegisterPage';
|
import { RegisterPage } from './ui/RegisterPage';
|
||||||
import { renderWithProviders } from '@/shared/lib/test/test-utils';
|
import { renderWithProviders } from '../../test/test-utils';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,12 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '../../test/server';
|
||||||
import { request, configureAuth } from './client';
|
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';
|
||||||
import {
|
|
||||||
setAccessToken,
|
|
||||||
getAccessToken,
|
|
||||||
setOnUnauthorized,
|
|
||||||
handleUnauthorized,
|
|
||||||
} from '@/entities/session/api/tokenManager';
|
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
configureAuth({
|
|
||||||
getAccessToken,
|
|
||||||
handleUnauthorized,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request', () => {
|
describe('request', () => {
|
||||||
|
|||||||
@ -1,22 +1,46 @@
|
|||||||
import type { ApiEnvelope, ApiResponseMeta, HealthResponse } from './responses';
|
import type {
|
||||||
|
ApiEnvelope,
|
||||||
|
ApiResponseMeta,
|
||||||
|
AuthResponse,
|
||||||
|
SearchResultItem,
|
||||||
|
HealthResponse,
|
||||||
|
} from './responses';
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
export type AuthConfig = {
|
let accessToken: string | null = null;
|
||||||
getAccessToken: () => string | null;
|
let onUnauthorized: (() => void) | null = null;
|
||||||
handleUnauthorized: () => Promise<boolean>;
|
let isRefreshing = false;
|
||||||
};
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
let authConfig: AuthConfig = {
|
export function setAccessToken(token: string | null) {
|
||||||
getAccessToken: () => null,
|
accessToken = token;
|
||||||
handleUnauthorized: async () => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function configureAuth(config: AuthConfig) {
|
|
||||||
authConfig = config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
export function getAccessToken(): string | null {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOnUnauthorized(cb: () => void) {
|
||||||
|
onUnauthorized = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokens(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const json = await res.json();
|
||||||
|
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||||
if (
|
if (
|
||||||
envelope.data &&
|
envelope.data &&
|
||||||
@ -33,6 +57,21 @@ export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiRespons
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUnauthorized(): Promise<boolean> {
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = refreshTokens().then((success) => {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
return success;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export async function request<T>(
|
export async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
params?: Record<string, string | undefined>,
|
params?: Record<string, string | undefined>,
|
||||||
@ -46,11 +85,8 @@ export async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (!options?.skipAuth) {
|
if (!options?.skipAuth && accessToken) {
|
||||||
const token = authConfig.getAccessToken();
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (options?.body && !(options.body instanceof FormData)) {
|
if (options?.body && !(options.body instanceof FormData)) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
@ -71,14 +107,13 @@ export async function request<T>(
|
|||||||
let res = await fetch(url.toString(), fetchOptions);
|
let res = await fetch(url.toString(), fetchOptions);
|
||||||
|
|
||||||
if (res.status === 401 && !options?.skipAuth) {
|
if (res.status === 401 && !options?.skipAuth) {
|
||||||
const refreshed = await authConfig.handleUnauthorized();
|
const refreshed = await handleUnauthorized();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
const token = authConfig.getAccessToken();
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
||||||
} else {
|
} else {
|
||||||
|
accessToken = null;
|
||||||
|
onUnauthorized?.();
|
||||||
throw new Error('Сессия истекла');
|
throw new Error('Сессия истекла');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -95,3 +130,15 @@ export async function request<T>(
|
|||||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||||
return request<HealthResponse>('/api/v1/health');
|
return request<HealthResponse>('/api/v1/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchSecurities(
|
||||||
|
q: string,
|
||||||
|
type: 'all' | 'share' | 'bond' = 'all',
|
||||||
|
limit = 20,
|
||||||
|
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
||||||
|
q,
|
||||||
|
type,
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
export { request, configureAuth, getHealth } from './client';
|
export {
|
||||||
|
request,
|
||||||
|
setAccessToken,
|
||||||
|
getAccessToken,
|
||||||
|
setOnUnauthorized,
|
||||||
|
getHealth,
|
||||||
|
searchSecurities,
|
||||||
|
} from './client';
|
||||||
export type {
|
export type {
|
||||||
ApiResponseMeta,
|
ApiResponseMeta,
|
||||||
ApiEnvelope,
|
ApiEnvelope,
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAllocationBar } from './ui/BrokerAllocationBar';
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
type AllocationBarItem = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
percent: number;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BrokerAllocationBar({
|
|
||||||
items,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
items: AllocationBarItem[];
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const positiveItems = items.filter((item) => item.value > 0);
|
|
||||||
|
|
||||||
if (positiveItems.length === 0) {
|
|
||||||
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="broker-allocation-bar">
|
|
||||||
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
|
||||||
{positiveItems.map((item) => (
|
|
||||||
<span
|
|
||||||
key={item.key}
|
|
||||||
className="broker-allocation-bar__segment"
|
|
||||||
style={{ width: `${item.percent}%`, background: item.color }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
|
||||||
{positiveItems.map((item) => (
|
|
||||||
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
|
||||||
<span
|
|
||||||
className="broker-allocation-bar__swatch"
|
|
||||||
style={{ background: item.color }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{item.percent.toFixed(0)}%</strong>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
|
|||||||
import { render, type RenderOptions } from '@testing-library/react';
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { SessionProvider } from '@/app/providers';
|
import { SessionProvider } from '../app/providers/SessionProvider';
|
||||||
|
|
||||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
queryClient?: QueryClient;
|
queryClient?: QueryClient;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { BondDetails } from './BondDetails';
|
import { BondDetails } from './BondDetails';
|
||||||
import { createMockBond } from '@/shared/lib/test/factories';
|
import { createMockBond } from '@/test/factories';
|
||||||
|
|
||||||
describe('BondDetails', () => {
|
describe('BondDetails', () => {
|
||||||
it('renders bond details', () => {
|
it('renders bond details', () => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||||
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '../../../entities/broker-position';
|
||||||
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart';
|
||||||
|
|
||||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export { BrokerAccountLayout, useBrokerAccountContext } from './ui/BrokerAccountLayout';
|
|
||||||
export type { BrokerAccountContext } from './ui/BrokerAccountLayout';
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||||
import type { BrokerAccountsAggregate } from '@/entities/broker-account';
|
import type { BrokerAccountsAggregate } from '../../../entities/broker-account/model/brokerAccountsOverview';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '../../../entities/broker-position';
|
||||||
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart';
|
||||||
|
|
||||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerAllocationChart } from './ui/BrokerAllocationChart';
|
export { BrokerAllocationBar, BrokerAllocationChart } from './ui/BrokerAllocationChart';
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import {
|
||||||
|
buildBrokerAllocation,
|
||||||
|
type BrokerAllocationItem,
|
||||||
|
} from '../../../entities/broker-position';
|
||||||
|
|
||||||
const RADIUS = 44;
|
const RADIUS = 44;
|
||||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
@ -20,6 +23,48 @@ function allocationCurrency(portfolio: BrokerPortfolio) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BrokerAllocationBar({
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
items: BrokerAllocationItem[];
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const positiveItems = items.filter((item) => item.value > 0);
|
||||||
|
|
||||||
|
if (positiveItems.length === 0) {
|
||||||
|
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-allocation-bar">
|
||||||
|
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.key}
|
||||||
|
className="broker-allocation-bar__segment"
|
||||||
|
style={{ width: `${item.percent}%`, background: item.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
||||||
|
<span
|
||||||
|
className="broker-allocation-bar__swatch"
|
||||||
|
style={{ background: item.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.percent.toFixed(0)}%</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
|
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||||
const { sectors, negative } = buildBrokerAllocation(portfolio);
|
const { sectors, negative } = buildBrokerAllocation(portfolio);
|
||||||
const currency = allocationCurrency(portfolio);
|
const currency = allocationCurrency(portfolio);
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import {
|
|||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
type BrokerOperationImpact,
|
type BrokerOperationImpact,
|
||||||
} from '@/entities/broker-operation';
|
} from '../../../entities/broker-operation';
|
||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath } from '../../../entities/broker-position';
|
||||||
|
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { DividendsTable } from './DividendsTable';
|
import { DividendsTable } from './DividendsTable';
|
||||||
import { createMockDividends } from '@/shared/lib/test/factories';
|
import { createMockDividends } from '@/test/factories';
|
||||||
|
|
||||||
describe('DividendsTable', () => {
|
describe('DividendsTable', () => {
|
||||||
it('renders title, date column and formatted amount with currency', () => {
|
it('renders title, date column and formatted amount with currency', () => {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '@/test/server';
|
||||||
import { SearchBar } from '@/widgets/search-bar';
|
import { SearchBar } from '@/widgets/search-bar';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { StockDetails } from './StockDetails';
|
import { StockDetails } from './StockDetails';
|
||||||
import { createMockShare } from '@/shared/lib/test/factories';
|
import { createMockShare } from '@/test/factories';
|
||||||
|
|
||||||
describe('StockDetails', () => {
|
describe('StockDetails', () => {
|
||||||
it('renders stock details', () => {
|
it('renders stock details', () => {
|
||||||
|
|||||||
@ -21,6 +21,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/lib/test/**"],
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/shared/lib/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
globals: true,
|
globals: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
# FSD Final Cleanup Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Complete FSD migration by fixing all remaining compliance gaps in apps/frontend/src/
|
|
||||||
|
|
||||||
**Architecture:** Eight phases covering: import aliases (8 files), BrokerAccountLayout move, search api/ layer, app layer barrel imports, test import aliases, BrokerAllocationChart move to shared/ui/, cross-entity import fix, missing barrel export.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase A: Move BrokerAllocationChart to shared/ui/
|
|
||||||
|
|
||||||
### Task A1: Create shared/ui/broker-allocation-chart
|
|
||||||
|
|
||||||
- `shared/ui/broker-allocation-chart/index.ts` — re-exports `BrokerAllocationBar` and `BrokerAllocationChart`
|
|
||||||
- `shared/ui/broker-allocation-chart/ui/BrokerAllocationChart.tsx` — copied from widgets/broker-allocation-chart, no code changes
|
|
||||||
|
|
||||||
### Task A2: Update consumers and delete old location
|
|
||||||
|
|
||||||
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx`: `@/widgets/broker-allocation-chart` → `@/shared/ui/broker-allocation-chart`
|
|
||||||
- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: same change
|
|
||||||
- `widgets/broker-account-card/ui/BrokerAccountCard.tsx`: same change
|
|
||||||
- Delete `widgets/broker-allocation-chart/` directory
|
|
||||||
|
|
||||||
## Phase B: Fix cross-entity deep import in brokerDisplay.ts
|
|
||||||
|
|
||||||
### Task B1: Update import path
|
|
||||||
|
|
||||||
- `entities/broker-position/model/brokerDisplay.ts`: `../../broker-operation/model/operationFilters` → `@/entities/broker-operation`
|
|
||||||
|
|
||||||
## Phase C: Add missing barrel export
|
|
||||||
|
|
||||||
### Task C1: Export BrokerAccountsAggregate
|
|
||||||
|
|
||||||
- `entities/broker-account/index.ts`: add `type BrokerAccountsAggregate` export
|
|
||||||
|
|
||||||
### Task C2: Update consumer to use barrel
|
|
||||||
|
|
||||||
- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: `@/entities/broker-account/model/...` → `@/entities/broker-account`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Task D1: Run tests, lint, build
|
|
||||||
|
|
||||||
- `npm run lint -w apps/frontend`
|
|
||||||
- `npm run test -w apps/frontend`
|
|
||||||
- `npm run build -w apps/frontend`
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# FSD Final Cleanup
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Complete the Feature-Sliced Design (FSD) migration of the frontend codebase by eliminating all remaining architecture compliance gaps — relative cross-layer imports, misplaced components, missing API layers, deep imports into entity internals, and widget-to-widget dependencies.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### R1: All imports use `@/` path aliases
|
|
||||||
No `../../../` or `../../` relative imports across the entire `src/` tree. Every import must use the `@/` alias pointing to `src/`.
|
|
||||||
|
|
||||||
### R2: BrokerAccountLayout lives in widgets layer
|
|
||||||
The `BrokerAccountLayout` component (layout with routing + `useOutletContext`) currently lives in `entities/broker-account/ui/`. It must be moved to `widgets/broker-account-layout/` since it is a page layout, not a business entity.
|
|
||||||
|
|
||||||
### R3: entities/search has an `api/` layer
|
|
||||||
The `searchSecurities` function — a domain API call — currently lives in `shared/api/client.ts`. It must be moved to `entities/search/api/searchApi.ts` so that each entity owns its domain calls.
|
|
||||||
|
|
||||||
### R4: App layer uses barrel imports
|
|
||||||
Files in `app/` must import from entity barrel files (`@/entities/session`) rather than deep-importing into `model/` or `api/` subdirectories.
|
|
||||||
|
|
||||||
### R5: Tests use `@/` path aliases
|
|
||||||
All test files must import `test/server` and `test/test-utils` via `@/` prefix instead of relative paths.
|
|
||||||
|
|
||||||
### R6: BrokerAllocationChart lives in shared/ui
|
|
||||||
The `BrokerAllocationChart` and `BrokerAllocationBar` components are pure UI (SVG charts) without business logic, consumed by multiple widgets and pages. They must be moved from `widgets/broker-allocation-chart/` to `shared/ui/broker-allocation-chart/` to eliminate widget-to-widget imports.
|
|
||||||
|
|
||||||
### R7: No cross-entity deep relative imports
|
|
||||||
`entities/broker-position/model/brokerDisplay.ts` must not use relative paths to import from `broker-operation/model/`. It must use the `@/entities/broker-operation` barrel.
|
|
||||||
|
|
||||||
### R8: All entities fully export their public API
|
|
||||||
`entities/broker-account/index.ts` must export the `BrokerAccountsAggregate` type. Consumers must use the barrel instead of deep-importing into `model/`.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Only modify imports and restructure components. Do not change business logic.
|
|
||||||
- Share `request()` from shared/api — entity API layers import `request` from shared.
|
|
||||||
- Do not restructure code that is not part of the specified changes.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Moving `styles.css` or `main.tsx` into `app/`
|
|
||||||
- Type deduplication (`responses.ts` vs `types.ts`)
|
|
||||||
- Moving `src/test/` to `shared/lib/tests/`
|
|
||||||
- Refactoring other entity barrel exports
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# Tasks: FSD Final Cleanup
|
|
||||||
|
|
||||||
## Phase 1: Fix import aliases
|
|
||||||
|
|
||||||
- [x] **Task 1:** Fix `../../../` → `@/` in 8 broker files (pages + widgets)
|
|
||||||
|
|
||||||
## Phase 2: Move BrokerAccountLayout to widgets
|
|
||||||
|
|
||||||
- [x] **Task 2:** Create `widgets/broker-account-layout/index.ts` and `ui/BrokerAccountLayout.tsx`
|
|
||||||
- [x] **Task 3:** Update all imports referencing old path, delete `entities/broker-account/ui/BrokerAccountLayout.tsx`
|
|
||||||
|
|
||||||
## Phase 3: Add api/ to entities/search
|
|
||||||
|
|
||||||
- [x] **Task 4:** Create `entities/search/api/searchApi.ts` with `searchSecurities`
|
|
||||||
- [x] **Task 5:** Update consumers, remove `searchSecurities` from shared
|
|
||||||
|
|
||||||
## Phase 4: Fix app layer deep imports
|
|
||||||
|
|
||||||
- [x] **Task 6:** Fix `AppLayout.tsx`, `SessionProvider.tsx`, `ProtectedRoute.tsx` to use `@/entities/session` barrel
|
|
||||||
|
|
||||||
## Phase 5: Fix test relative imports
|
|
||||||
|
|
||||||
- [x] **Task 7:** Fix `test/test-utils.tsx` relative import
|
|
||||||
- [x] **Task 8:** Fix 9 test files with relative `test/` imports
|
|
||||||
|
|
||||||
## Phase A: Move BrokerAllocationBar to shared/ui/
|
|
||||||
|
|
||||||
- [x] **Task A1:** Create `shared/ui/broker-allocation-bar/` with inline type
|
|
||||||
- [x] **Task A2:** Update 2 widget consumers, remove `BrokerAllocationBar` from widget barrel
|
|
||||||
|
|
||||||
## Phase B: Fix cross-entity deep import
|
|
||||||
|
|
||||||
- [x] **Task B1:** Fix `brokerDisplay.ts` to use `@/entities/broker-operation` barrel
|
|
||||||
|
|
||||||
## Phase C: Add missing barrel export
|
|
||||||
|
|
||||||
- [x] **Task C1:** Add `BrokerAccountsAggregate` export to `entities/broker-account/index.ts`
|
|
||||||
- [x] **Task C2:** Update `BrokerAccountsSummary.tsx` to import from barrel
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- [x] **Task D1:** Run tests, lint, build — all pass
|
|
||||||
Loading…
x
Reference in New Issue
Block a user