chore: codify React component style

This commit is contained in:
2026-08-25 15:33:20 +03:00
parent 268acdf2e1
commit d10e1dd182
65 changed files with 711 additions and 365 deletions
+1
View File
@@ -28,6 +28,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri
## Enforceable boundaries ## Enforceable boundaries
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript. - Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
- React component declaration, props и export style: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), часть исполняется Biome, часть является mandatory review convention.
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`. - FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls. - HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно. - UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно.
+26 -2
View File
@@ -15,7 +15,31 @@
}, },
"linter": { "linter": {
"rules": { "rules": {
"preset": "recommended" "preset": "recommended",
"nursery": {
"useReactFunctionComponentDefinition": {
"level": "error",
"options": {
"namedComponents": "arrowFunction"
}
}
},
"style": {
"noDefaultExport": "error",
"useExportsLast": "error"
}
} }
} },
"overrides": [
{
"includes": ["vite.config.ts", "vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
} }
+18
View File
@@ -11,6 +11,24 @@
- Зависимости используют exact versions; не добавлять semver ranges. - Зависимости используют exact versions; не добавлять semver ranges.
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits. - Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
## React component style
Biome-enforced guardrails:
- Named React components объявлять как const-valued components, а не function declarations.
- Не использовать default exports.
- Export statements держать после declarations тела module.
Mandatory review conventions:
- React components типизировать через `FC`: `const Component: FC<Props> = (props) => { ... }`; если props нет — `const Component: FC = () => { ... }`.
- Props описывать named type рядом с component либо в `Component.types.ts`, если type переиспользуется или file становится крупным.
- Не использовать inline props typing в component parameters.
- Для components только с `children` использовать `FC<PropsWithChildren>` без отдельного props alias. Если кроме `children` есть другие props, использовать named props type на основе `PropsWithChildren<{ ... }>`.
- Не destructure props в component parameters; принимать `props` и destructure внутри component body.
- Обычный module export surface сводить к одному нижнему named export block, когда это выражается читаемо без потери public API.
- Чистые barrel files должны использовать прямой re-export без промежуточного import: `export { Component } from "./Component";`.
## File roles ## File roles
- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view. - `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
+13 -5
View File
@@ -3,7 +3,7 @@ import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024; const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
function manifestEntries(manifest) { function manifestEntries(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
@@ -21,7 +21,7 @@ function assetFile(entry, entryName) {
return entry.file; return entry.file;
} }
export function getInitialAssetFiles(manifest) { function getInitialAssetFiles(manifest) {
const entries = manifestEntries(manifest); const entries = manifestEntries(manifest);
const entryCandidates = entries.filter( const entryCandidates = entries.filter(
([, entry]) => entry?.isEntry === true, ([, entry]) => entry?.isEntry === true,
@@ -46,7 +46,7 @@ export function getInitialAssetFiles(manifest) {
return files; return files;
} }
export function measureAssets(files, getSize) { function measureAssets(files, getSize) {
return files.reduce((total, file) => { return files.reduce((total, file) => {
const size = getSize(file); const size = getSize(file);
if (!Number.isSafeInteger(size) || size < 0) { if (!Number.isSafeInteger(size) || size < 0) {
@@ -56,7 +56,7 @@ export function measureAssets(files, getSize) {
}, 0); }, 0);
} }
export function assertBundleBudget(initialBytes) { function assertBundleBudget(initialBytes) {
if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) { if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
throw new Error( throw new Error(
`Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`, `Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
@@ -64,7 +64,7 @@ export function assertBundleBudget(initialBytes) {
} }
} }
export function getLazyRouteAssetSizes(manifest, getSize, baseline) { function getLazyRouteAssetSizes(manifest, getSize, baseline) {
const routeEntries = manifestEntries(manifest).filter( const routeEntries = manifestEntries(manifest).filter(
([, entry]) => ([, entry]) =>
entry?.isDynamicEntry === true && typeof entry.src === "string", entry?.isDynamicEntry === true && typeof entry.src === "string",
@@ -142,3 +142,11 @@ if (
} }
await run(mode); await run(mode);
} }
export {
assertBundleBudget,
getInitialAssetFiles,
getLazyRouteAssetSizes,
INITIAL_BUNDLE_BUDGET_BYTES,
measureAssets,
};
+16 -9
View File
@@ -1,4 +1,5 @@
import { Loader } from "@mantine/core"; import { Loader } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router"; import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -40,11 +41,14 @@ const ProtectedLayout = lazy(async () => {
return { default: ProtectedLayout }; return { default: ProtectedLayout };
}); });
function LazyPage({ children }: { children: React.ReactNode }) { const LazyPage: FC<PropsWithChildren> = (props) => {
return <Suspense fallback={<Loader />}>{children}</Suspense>; const { children } = props;
}
function Protected({ children }: { children: React.ReactNode }) { return <Suspense fallback={<Loader />}>{children}</Suspense>;
};
const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth(); const { user, ready } = useAuth();
const location = useLocation(); const location = useLocation();
if (!ready) return null; if (!ready) return null;
@@ -57,15 +61,16 @@ function Protected({ children }: { children: React.ReactNode }) {
replace replace
/> />
); );
} };
function Public({ children }: { children: React.ReactNode }) { const Public: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth(); const { user, ready } = useAuth();
if (!ready) return null; if (!ready) return null;
return user ? <Navigate to="/accounts" replace /> : children; return user ? <Navigate to="/accounts" replace /> : children;
} };
export function AppRoutes() { const AppRoutes: FC = () => {
return ( return (
<Routes> <Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} /> <Route path="/" element={<Navigate to="/accounts" replace />} />
@@ -149,4 +154,6 @@ export function AppRoutes() {
/> />
</Routes> </Routes>
); );
} };
export { AppRoutes };
+5 -2
View File
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { AppBreadcrumbs } from "@/app/ui"; import { AppBreadcrumbs } from "@/app/ui";
import { AppShell } from "@/shared/ui"; import { AppShell } from "@/shared/ui";
export function ProtectedLayout() { const ProtectedLayout: FC = () => {
return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />; return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
} };
export { ProtectedLayout };
+11 -6
View File
@@ -1,5 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { RouteErrorBoundary } from "./RouteErrorBoundary";
@@ -9,10 +10,11 @@ vi.mock("@/features/auth", () => ({
useAuth: () => ({ user: authenticated ? {} : null }), useAuth: () => ({ user: authenticated ? {} : null }),
})); }));
function ThrowingRoute(): never { const ThrowingRoute: FC = () => {
throw new Error("test route failure"); throw new Error("test route failure");
} };
function HealthyRoute() {
const HealthyRoute: FC = () => {
return ( return (
<div> <div>
{useLocation().pathname === "/healthy" ? ( {useLocation().pathname === "/healthy" ? (
@@ -22,9 +24,11 @@ function HealthyRoute() {
)} )}
</div> </div>
); );
} };
function RouteHarness() {
const RouteHarness: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<button type="button" onClick={() => navigate("/healthy")}> <button type="button" onClick={() => navigate("/healthy")}>
@@ -35,7 +39,8 @@ function RouteHarness() {
</RouteErrorBoundary> </RouteErrorBoundary>
</> </>
); );
} };
function renderBoundary(path: string) { function renderBoundary(path: string) {
return render( return render(
<MantineProvider> <MantineProvider>
+19 -12
View File
@@ -1,24 +1,29 @@
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
type Props = { type BoundaryProps = PropsWithChildren<{
children: React.ReactNode;
routeKey: string; routeKey: string;
authenticated: boolean; authenticated: boolean;
}>;
type RouteErrorFallbackProps = {
authenticated: boolean;
}; };
type State = { hasError: boolean }; type State = { hasError: boolean };
class Boundary extends Component<Props, State> { class Boundary extends Component<BoundaryProps, State> {
state: State = { hasError: false }; state: State = { hasError: false };
static getDerivedStateFromError(): State { static getDerivedStateFromError(): State {
return { hasError: true }; return { hasError: true };
} }
componentDidUpdate(previousProps: Props) { componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false }); this.setState({ hasError: false });
} }
@@ -32,9 +37,11 @@ class Boundary extends Component<Props, State> {
} }
} }
function RouteErrorFallback({ authenticated }: { authenticated: boolean }) { const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const to = authenticated ? "/accounts" : "/login"; const to = authenticated ? "/accounts" : "/login";
return ( return (
<Stack> <Stack>
<Title order={1}>{t("routeError.title")}</Title> <Title order={1}>{t("routeError.title")}</Title>
@@ -44,18 +51,18 @@ function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
</Button> </Button>
</Stack> </Stack>
); );
} };
export function RouteErrorBoundary({ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
children, const { children } = props;
}: {
children: React.ReactNode;
}) {
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}> <Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
{children} {children}
</Boundary> </Boundary>
); );
} };
export { RouteErrorBoundary };
+3 -1
View File
@@ -1,6 +1,6 @@
import { createTheme } from "@mantine/core"; import { createTheme } from "@mantine/core";
export const theme = createTheme({ const theme = createTheme({
primaryColor: "blue", primaryColor: "blue",
defaultRadius: "md", defaultRadius: "md",
fontFamily: fontFamily:
@@ -20,3 +20,5 @@ export const theme = createTheme({
], ],
}, },
}); });
export { theme };
+5 -2
View File
@@ -1,5 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FC } from "react";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { theme } from "@/app/theme"; import { theme } from "@/app/theme";
@@ -9,7 +10,7 @@ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }, defaultOptions: { queries: { refetchOnWindowFocus: false } },
}); });
export function App() { const App: FC = () => {
return ( return (
<MantineProvider theme={theme} forceColorScheme="light"> <MantineProvider theme={theme} forceColorScheme="light">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -23,4 +24,6 @@ export function App() {
</QueryClientProvider> </QueryClientProvider>
</MantineProvider> </MantineProvider>
); );
} };
export { App };
+23 -14
View File
@@ -1,11 +1,24 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useMatch } from "react-router"; import { Link, useMatch } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
type Crumb = { label: string; to?: string }; type Crumb = { label: string; to?: string };
function BreadcrumbTrail({ items }: { items: Crumb[] }) { type BreadcrumbTrailProps = {
items: Crumb[];
};
type AccountBreadcrumbsProps = {
accountId: string;
events: boolean;
operations: boolean;
};
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<nav <nav
aria-label={t("breadcrumbs.label")} aria-label={t("breadcrumbs.label")}
@@ -33,23 +46,17 @@ function BreadcrumbTrail({ items }: { items: Crumb[] }) {
))} ))}
</nav> </nav>
); );
} };
function AccountBreadcrumbs({ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
accountId, const { accountId, events, operations } = props;
events,
operations,
}: {
accountId: string;
events: boolean;
operations: boolean;
}) {
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const accountName = const accountName =
accounts?.find((account) => account.id === accountId)?.name ?? accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback"); t("breadcrumbs.accountFallback");
const accountPath = `/accounts/${accountId}`; const accountPath = `/accounts/${accountId}`;
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={ items={
@@ -72,9 +79,9 @@ function AccountBreadcrumbs({
} }
/> />
); );
} };
export function AppBreadcrumbs() { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const accountOperations = useMatch({ const accountOperations = useMatch({
path: "/accounts/:accountId/operations", path: "/accounts/:accountId/operations",
@@ -117,4 +124,6 @@ export function AppBreadcrumbs() {
if (settings) if (settings)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
return null; return null;
} };
export { AppBreadcrumbs };
@@ -1,7 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { import {
@@ -25,11 +25,15 @@ describe("useBrokerageOperations", () => {
}), }),
).toEqual({ categories: ["income", "tax"], cursor: "next" }); ).toEqual({ categories: ["income", "tax"], cursor: "next" });
const client = new QueryClient(); const client = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper: FC<PropsWithChildren> = (props) => {
<MantineProvider> const { children } = props;
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider> return (
); <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook( const { result } = renderHook(
() => useBrokerageOperations("account-1", { limit: 5 }), () => useBrokerageOperations("account-1", { limit: 5 }),
@@ -4,14 +4,14 @@ import type { paths } from "@/shared/api/generated/schema";
type OperationsEndpoint = type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
export type BrokerageOperation = type BrokerageOperation =
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number]; OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
type BrokerageOperationsQuery = NonNullable< type BrokerageOperationsQuery = NonNullable<
OperationsEndpoint["parameters"]["query"] OperationsEndpoint["parameters"]["query"]
>; >;
export type OperationCategory = BrokerageOperation["category"]; type OperationCategory = BrokerageOperation["category"];
export const operationCategories = [ const operationCategories = [
"trade", "trade",
"income", "income",
"tax", "tax",
@@ -20,9 +20,7 @@ export const operationCategories = [
"other", "other",
] as const satisfies readonly OperationCategory[]; ] as const satisfies readonly OperationCategory[];
export function brokerageOperationsQueryParams( function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
query: BrokerageOperationsQuery,
) {
return { return {
...(query.from ? { from: query.from } : {}), ...(query.from ? { from: query.from } : {}),
...(query.to ? { to: query.to } : {}), ...(query.to ? { to: query.to } : {}),
@@ -34,7 +32,7 @@ export function brokerageOperationsQueryParams(
}; };
} }
export function useBrokerageOperations( function useBrokerageOperations(
accountId: string | undefined, accountId: string | undefined,
query: BrokerageOperationsQuery = {}, query: BrokerageOperationsQuery = {},
) { ) {
@@ -50,3 +48,11 @@ export function useBrokerageOperations(
retry: false, retry: false,
}); });
} }
export {
type BrokerageOperation,
brokerageOperationsQueryParams,
type OperationCategory,
operationCategories,
useBrokerageOperations,
};
+2 -4
View File
@@ -1,8 +1,6 @@
export type {
BrokerageOperation,
OperationCategory,
} from "./api/useBrokerageOperations";
export { export {
type BrokerageOperation,
type OperationCategory,
operationCategories, operationCategories,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -10,11 +10,17 @@ import { useBrokerageAccounts } from "./useBrokerageAccounts";
function createWrapper() { function createWrapper() {
const client = new QueryClient(); const client = new QueryClient();
return ({ children }: { children: ReactNode }) => ( const TestWrapper: FC<PropsWithChildren> = (props) => {
<MantineProvider> const { children } = props;
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider> return (
); <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
} }
describe("useBrokerageAccounts", () => { describe("useBrokerageAccounts", () => {
@@ -4,16 +4,21 @@ import type { paths } from "@/shared/api/generated/schema";
const brokerageAccountsQueryOptions = () => const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
export const brokerageAccountsQueryKey = const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
brokerageAccountsQueryOptions().queryKey;
export type BrokerageAccount = type BrokerageAccount =
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
export function useBrokerageAccounts() { function useBrokerageAccounts() {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...brokerageAccountsQueryOptions(), ...brokerageAccountsQueryOptions(),
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
}); });
} }
export {
type BrokerageAccount,
brokerageAccountsQueryKey,
useBrokerageAccounts,
};
+1 -1
View File
@@ -1,5 +1,5 @@
export type { BrokerageAccount } from "./api/useBrokerageAccounts";
export { export {
type BrokerageAccount,
brokerageAccountsQueryKey, brokerageAccountsQueryKey,
useBrokerageAccounts, useBrokerageAccounts,
} from "./api/useBrokerageAccounts"; } from "./api/useBrokerageAccounts";
@@ -1,11 +1,13 @@
import type { BrokerageAccount } from "../api/useBrokerageAccounts"; import type { BrokerageAccount } from "../api/useBrokerageAccounts";
export function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const; return `accounts.types.${type}` as const;
} }
export function errorCode(error: unknown): string | undefined { function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error return typeof error === "object" && error !== null && "code" in error
? String(error.code) ? String(error.code)
: undefined; : undefined;
} }
export { accountTypeKey, errorCode };
@@ -1,4 +1,5 @@
import { Card, SimpleGrid, Stack, Text } from "@mantine/core"; import { Card, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { BrokerageAccount } from "../api/useBrokerageAccounts"; import type { BrokerageAccount } from "../api/useBrokerageAccounts";
@@ -7,8 +8,10 @@ import classes from "./BrokerageAccountList.module.css";
type BrokerageAccountListProps = { accounts: BrokerageAccount[] }; type BrokerageAccountListProps = { accounts: BrokerageAccount[] };
export function BrokerageAccountList({ accounts }: BrokerageAccountListProps) { const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{accounts.map((account) => ( {accounts.map((account) => (
@@ -43,4 +46,6 @@ export function BrokerageAccountList({ accounts }: BrokerageAccountListProps) {
))} ))}
</SimpleGrid> </SimpleGrid>
); );
} };
export { BrokerageAccountList };
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -10,11 +10,17 @@ import { useBrokerageEvents } from "./useBrokerageEvents";
function createWrapper() { function createWrapper() {
const client = new QueryClient(); const client = new QueryClient();
return ({ children }: { children: ReactNode }) => ( const TestWrapper: FC<PropsWithChildren> = (props) => {
<MantineProvider> const { children } = props;
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider> return (
); <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
} }
describe("useBrokerageEvents", () => { describe("useBrokerageEvents", () => {
@@ -3,10 +3,10 @@ import type { paths } from "@/shared/api/generated/schema";
type EventsEndpoint = type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
export type BrokerageEvent = type BrokerageEvent =
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number]; EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
export function useBrokerageEvents(accountId: string | undefined) { function useBrokerageEvents(accountId: string | undefined) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
@@ -18,3 +18,5 @@ export function useBrokerageEvents(accountId: string | undefined) {
retry: false, retry: false,
}); });
} }
export { type BrokerageEvent, useBrokerageEvents };
@@ -1,7 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
@@ -17,11 +17,15 @@ describe("usePortfolio", () => {
clear: vi.fn(), clear: vi.fn(),
}); });
const client = new QueryClient(); const client = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper: FC<PropsWithChildren> = (props) => {
<MantineProvider> const { children } = props;
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider> return (
); <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(() => usePortfolio("account-1"), { const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper, wrapper,
+4 -2
View File
@@ -6,10 +6,10 @@ const portfolioQueryPolicy = {
retry: false, retry: false,
} as const; } as const;
export type Portfolio = type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
export function usePortfolio(accountId: string | undefined) { function usePortfolio(accountId: string | undefined) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
@@ -20,3 +20,5 @@ export function usePortfolio(accountId: string | undefined) {
...portfolioQueryPolicy, ...portfolioQueryPolicy,
}); });
} }
export { type Portfolio, usePortfolio };
+1 -2
View File
@@ -1,5 +1,4 @@
export type { Portfolio } from "./api/usePortfolio"; export { type Portfolio, usePortfolio } from "./api/usePortfolio";
export { usePortfolio } from "./api/usePortfolio";
export { export {
formatMoney, formatMoney,
formatShare, formatShare,
+13 -7
View File
@@ -12,12 +12,12 @@ function formatDecimal(value: string): string {
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`; return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
} }
export function formatMoney(money: Money | null): string { function formatMoney(money: Money | null): string {
if (!money) return "—"; if (!money) return "—";
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
} }
export function formatShare( function formatShare(
numerator: Money | null, numerator: Money | null,
denominator: Money | null, denominator: Money | null,
): string | null { ): string | null {
@@ -32,7 +32,7 @@ export function formatShare(
return `${rounded} %`; return `${rounded} %`;
} }
export function formatSnapshotTime(value: string): string { function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short", timeStyle: "short",
@@ -40,7 +40,7 @@ export function formatSnapshotTime(value: string): string {
}).format(new Date(value)); }).format(new Date(value));
} }
export function portfolioErrorCode(error: unknown): string | undefined { function portfolioErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error return typeof error === "object" && error !== null && "code" in error
? String(error.code) ? String(error.code)
: undefined; : undefined;
@@ -48,11 +48,17 @@ export function portfolioErrorCode(error: unknown): string | undefined {
const logoSize = 160; const logoSize = 160;
export function instrumentLogoUrl( function instrumentLogoUrl(logoName: string | null | undefined): string | null {
logoName: string | null | undefined,
): string | null {
const stem = logoName?.trim().replace(/\.png$/i, ""); const stem = logoName?.trim().replace(/\.png$/i, "");
return stem return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png` ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: null; : null;
} }
export {
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode,
};
+28 -16
View File
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { apiClient } from "@/shared/api"; import { apiClient } from "@/shared/api";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -22,11 +22,15 @@ describe("AuthProvider", () => {
return HttpResponse.json([]); return HttpResponse.json([]);
}), }),
); );
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper: FC<PropsWithChildren> = (props) => {
<QueryClientProvider client={new QueryClient()}> const { children } = props;
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider> return (
); <QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
@@ -39,11 +43,15 @@ describe("AuthProvider", () => {
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
const queryClient = new QueryClient(); const queryClient = new QueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(["cached-query"], { value: "stale" });
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper: FC<PropsWithChildren> = (props) => {
<QueryClientProvider client={queryClient}> const { children } = props;
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider> return (
); <QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
@@ -71,11 +79,15 @@ describe("AuthProvider", () => {
); );
const queryClient = new QueryClient(); const queryClient = new QueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(["cached-query"], { value: "stale" });
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper: FC<PropsWithChildren> = (props) => {
<QueryClientProvider client={queryClient}> const { children } = props;
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider> return (
); <QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => await waitFor(() =>
+7 -4
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { FC, PropsWithChildren } from "react";
import { import {
createContext, createContext,
useCallback, useCallback,
@@ -25,7 +26,6 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
async function request( async function request(
@@ -40,7 +40,8 @@ async function request(
return data as Authentication; return data as Authentication;
} }
export function AuthProvider({ children }: { children: React.ReactNode }) { const AuthProvider: FC<PropsWithChildren> = (props) => {
const { children } = props;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
@@ -138,10 +139,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
{children} {children}
</AuthContext> </AuthContext>
); );
} };
export function useAuth() { function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) throw new Error("AuthProvider is required"); if (!context) throw new Error("AuthProvider is required");
return context; return context;
} }
export { AuthProvider, useAuth };
+9 -7
View File
@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -50,14 +51,13 @@ const registerSchema = loginSchema.refine(
}), }),
); );
type AuthFormValues = z.input<typeof loginSchema>; type AuthFormValues = z.input<typeof loginSchema>;
type AuthFormProps = {
export function AuthForm({
register,
onSubmit,
}: {
register?: true; register?: true;
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
}) { };
const AuthForm: FC<AuthFormProps> = (props) => {
const { register, onSubmit } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const schema = register ? registerSchema : loginSchema; const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({ const methods = useForm<AuthFormValues>({
@@ -114,4 +114,6 @@ export function AuthForm({
</form> </form>
</FormProvider> </FormProvider>
); );
} };
export { AuthForm };
+6 -2
View File
@@ -1,13 +1,15 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth"; import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm"; import { AuthForm } from "./AuthForm";
export function LoginForm() { const LoginForm: FC = () => {
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const returnTo = const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts"; (location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return ( return (
<AuthForm <AuthForm
onSubmit={async (email, password) => { onSubmit={async (email, password) => {
@@ -16,4 +18,6 @@ export function LoginForm() {
}} }}
/> />
); );
} };
export { LoginForm };
+6 -2
View File
@@ -1,13 +1,15 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth"; import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm"; import { AuthForm } from "./AuthForm";
export function RegisterForm() { const RegisterForm: FC = () => {
const { register } = useAuth(); const { register } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const returnTo = const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts"; (location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return ( return (
<AuthForm <AuthForm
register register
@@ -17,4 +19,6 @@ export function RegisterForm() {
}} }}
/> />
); );
} };
export { RegisterForm };
@@ -7,6 +7,7 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -24,6 +25,9 @@ import {
type Status = type Status =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
type TbankConnectionSectionLoadedProps = {
status: Status;
};
function errorCode(error: unknown): string | undefined { function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error return typeof error === "object" && error !== null && "code" in error
@@ -31,15 +35,18 @@ function errorCode(error: unknown): string | undefined {
: undefined; : undefined;
} }
export function TbankConnectionSection() { const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel(); const viewModel = useTbankConnectionViewModel();
if (viewModel.state === "loading") return <TbankConnectionLoading />; if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />; return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />; return <TbankConnectionSectionLoaded status={viewModel.status} />;
} };
function TbankConnectionSectionLoaded({ status }: { status: Status }) { const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
props,
) => {
const { status } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false); const [deleteOpened, setDeleteOpened] = useState(false);
@@ -151,4 +158,6 @@ function TbankConnectionSectionLoaded({ status }: { status: Status }) {
</Modal> </Modal>
</Stack> </Stack>
); );
} };
export { TbankConnectionSection };
@@ -1,12 +1,20 @@
import { Alert, Button, Text } from "@mantine/core"; import { Alert, Button, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function TbankConnectionLoading() { type TbankConnectionErrorProps = {
retry: () => void;
};
const TbankConnectionLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return <Text>{t("states.loadingText")}</Text>; return <Text>{t("states.loadingText")}</Text>;
} };
export function TbankConnectionError({ retry }: { retry: () => void }) {
const TbankConnectionError: FC<TbankConnectionErrorProps> = (props) => {
const { retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Alert color="red" title={t("tbank.statusError")}> <Alert color="red" title={t("tbank.statusError")}>
<Button variant="subtle" onClick={retry}> <Button variant="subtle" onClick={retry}>
@@ -14,4 +22,6 @@ export function TbankConnectionError({ retry }: { retry: () => void }) {
</Button> </Button>
</Alert> </Alert>
); );
} };
export { TbankConnectionError, TbankConnectionLoading };
@@ -5,9 +5,9 @@ import { apiQueryClient } from "@/shared/api";
const tbankConnectionQueryOptions = () => const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
export const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey; const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey;
export function useTbankConnectionViewModel() { function useTbankConnectionViewModel() {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
const status = useQuery({ const status = useQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
@@ -18,3 +18,5 @@ export function useTbankConnectionViewModel() {
return { state: "error" as const, retry: () => status.refetch() }; return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data }; return { state: "loaded" as const, status: status.data };
} }
export { tbankConnectionQueryKey, useTbankConnectionViewModel };
@@ -1,4 +1,5 @@
import { Button, Card, Group, Stack, Text, Title } from "@mantine/core"; import { Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import {
@@ -8,7 +9,19 @@ import {
import { formatMoney } from "@/entities/portfolio"; import { formatMoney } from "@/entities/portfolio";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
export function AccountEventsPage() { type AccountEventsPageErrorProps = {
retry: () => void;
};
type AccountEventsPageLoadedProps = {
events: BrokerageEvent[];
};
type AccountEventRowProps = {
event: BrokerageEvent;
};
const AccountEventsPage: FC = () => {
const { accountId } = useParams(); const { accountId } = useParams();
const events = useBrokerageEvents(accountId); const events = useBrokerageEvents(accountId);
if (events.isLoading) return <AccountEventsPageLoading />; if (events.isLoading) return <AccountEventsPageLoading />;
@@ -17,10 +30,11 @@ export function AccountEventsPage() {
if (!events.data) return <AccountEventsPageLoading />; if (!events.data) return <AccountEventsPageLoading />;
if (!events.data.events.length) return <AccountEventsPageEmpty />; if (!events.data.events.length) return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={events.data.events} />; return <AccountEventsPageLoaded events={events.data.events} />;
} };
function AccountEventsPageLoading() { const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
loading loading
@@ -28,10 +42,12 @@ function AccountEventsPageLoading() {
text={t("states.loadingText")} text={t("states.loadingText")}
/> />
); );
} };
function AccountEventsPageError({ retry }: { retry: () => void }) { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
error error
@@ -40,10 +56,11 @@ function AccountEventsPageError({ retry }: { retry: () => void }) {
action={<Button onClick={retry}>{t("events.retry")}</Button>} action={<Button onClick={retry}>{t("events.retry")}</Button>}
/> />
); );
} };
function AccountEventsPageEmpty() { const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack maw={960} gap="lg"> <Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
@@ -51,10 +68,12 @@ function AccountEventsPageEmpty() {
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} /> <StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack> </Stack>
); );
} };
function AccountEventsPageLoaded({ events }: { events: BrokerageEvent[] }) { const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { events } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack maw={960} gap="lg"> <Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
@@ -67,10 +86,12 @@ function AccountEventsPageLoaded({ events }: { events: BrokerageEvent[] }) {
))} ))}
</Stack> </Stack>
); );
} };
function AccountEventRow({ event }: { event: BrokerageEvent }) { const AccountEventRow: FC<AccountEventRowProps> = (props) => {
const { event } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card withBorder> <Card withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap"> <Group justify="space-between" align="flex-start" wrap="wrap">
@@ -94,7 +115,7 @@ function AccountEventRow({ event }: { event: BrokerageEvent }) {
</Group> </Group>
</Card> </Card>
); );
} };
function formatCalendarDate(date: string) { function formatCalendarDate(date: string) {
return new Intl.DateTimeFormat("ru-RU", { return new Intl.DateTimeFormat("ru-RU", {
@@ -102,3 +123,5 @@ function formatCalendarDate(date: string) {
timeZone: "UTC", timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`)); }).format(new Date(`${date}T00:00:00Z`));
} }
export { AccountEventsPage };
@@ -8,6 +8,7 @@ import {
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useParams } from "react-router"; import { useParams } from "react-router";
@@ -28,7 +29,11 @@ function dateEnd(value: string) {
return value ? `${value}T23:59:59.999Z` : undefined; return value ? `${value}T23:59:59.999Z` : undefined;
} }
export function AccountOperationsPage() { type OperationRowProps = {
operation: BrokerageOperation;
};
const AccountOperationsPage: FC = () => {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [from, setFrom] = useState(""); const [from, setFrom] = useState("");
@@ -147,10 +152,12 @@ export function AccountOperationsPage() {
) : null} ) : null}
</Stack> </Stack>
); );
} };
export function OperationRow({ operation }: { operation: BrokerageOperation }) { const OperationRow: FC<OperationRowProps> = (props) => {
const { operation } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card withBorder> <Card withBorder>
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
@@ -176,4 +183,6 @@ export function OperationRow({ operation }: { operation: BrokerageOperation }) {
</Group> </Group>
</Card> </Card>
); );
} };
export { AccountOperationsPage, OperationRow };
+5 -3
View File
@@ -1,3 +1,4 @@
import type { FC } from "react";
import { import {
AccountPageEmpty, AccountPageEmpty,
AccountPageError, AccountPageError,
@@ -6,7 +7,7 @@ import {
} from "./AccountPage.view"; } from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
export function AccountPage() { const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel(); const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />; if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />; if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
@@ -33,11 +34,12 @@ export function AccountPage() {
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
/> />
); );
} };
export { export {
AccountPage,
AccountPageEmpty, AccountPageEmpty,
AccountPageError, AccountPageError,
AccountPageLoaded, AccountPageLoaded,
AccountPageLoading, AccountPageLoading,
} from "./AccountPage.view"; };
+63 -46
View File
@@ -1,4 +1,5 @@
import { Alert, Button, Stack, Text } from "@mantine/core"; import { Alert, Button, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
@@ -22,8 +23,34 @@ import {
Section, Section,
} from "./components"; } from "./components";
export function AccountPageLoading() { type AccountPageErrorProps = {
code?: string;
retry: () => void;
};
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
};
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
};
const AccountPageLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
loading loading
@@ -31,15 +58,10 @@ export function AccountPageLoading() {
text={t("states.loadingText")} text={t("states.loadingText")}
/> />
); );
} };
export function AccountPageError({ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
code, const { code, retry } = props;
retry,
}: {
code?: string;
retry: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
if (code === "NOT_FOUND") if (code === "NOT_FOUND")
return ( return (
@@ -84,24 +106,19 @@ export function AccountPageError({
action={<Button onClick={retry}>{t("accounts.retry")}</Button>} action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/> />
); );
} };
export function AccountPageEmpty({ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
account, const {
refreshing = false, account,
refreshError = false, refreshing = false,
retry, refreshError = false,
recentOperations, retry,
recentEvents, recentOperations,
}: { recentEvents,
account?: BrokerageAccount; } = props;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<AccountHeading account={account} /> <AccountHeading account={account} />
@@ -118,28 +135,21 @@ export function AccountPageEmpty({
<RecentEvents events={recentEvents} /> <RecentEvents events={recentEvents} />
</Stack> </Stack>
); );
} };
export function AccountPageLoaded({ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
account, const {
portfolio, account,
positionSections, portfolio,
refreshing = false, positionSections,
refreshError = false, refreshing = false,
retry, refreshError = false,
recentOperations, retry,
recentEvents, recentOperations,
}: { recentEvents,
account?: BrokerageAccount; } = props;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
<AccountHeading account={account} /> <AccountHeading account={account} />
@@ -171,10 +181,17 @@ export function AccountPageLoaded({
<RecentEvents events={recentEvents} /> <RecentEvents events={recentEvents} />
</Stack> </Stack>
); );
} };
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks"; if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds"; if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions"; return "portfolio.otherPositions";
} }
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
+5 -3
View File
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router"; import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
@@ -60,7 +60,9 @@ function configureTestAuth() {
}); });
} }
function wrapper({ children }: { children: ReactNode }) { const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return ( return (
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<MantineProvider> <MantineProvider>
@@ -72,7 +74,7 @@ function wrapper({ children }: { children: ReactNode }) {
</MantineProvider> </MantineProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} };
function mockLoadedAccountPage() { function mockLoadedAccountPage() {
server.use( server.use(
+16 -7
View File
@@ -22,20 +22,20 @@ import {
type PositionSectionKind = "stocks" | "bonds" | "other"; type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative"; type AccountPageReturnTone = "positive" | "negative";
export type AccountPagePosition = Portfolio["positions"][number] & { type AccountPagePosition = Portfolio["positions"][number] & {
shareText: string | null; shareText: string | null;
returnText: string | null; returnText: string | null;
returnTone?: AccountPageReturnTone; returnTone?: AccountPageReturnTone;
logoSrc: string | null; logoSrc: string | null;
}; };
export type AccountPagePositionSection = { type AccountPagePositionSection = {
kind: PositionSectionKind; kind: PositionSectionKind;
wide: boolean; wide: boolean;
positions: AccountPagePosition[]; positions: AccountPagePosition[];
}; };
export type AccountPageRecentOperations = type AccountPageRecentOperations =
| { | {
state: "loading" | "error" | "empty"; state: "loading" | "error" | "empty";
to: string; to: string;
@@ -46,7 +46,7 @@ export type AccountPageRecentOperations =
items: BrokerageOperation[]; items: BrokerageOperation[];
}; };
export type AccountPageRecentEvents = type AccountPageRecentEvents =
| { | {
state: "loading" | "error" | "empty"; state: "loading" | "error" | "empty";
to: string; to: string;
@@ -77,13 +77,13 @@ type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty"; state: "empty";
}; };
export type AccountPageViewModel = type AccountPageViewModel =
| { state: "loading" } | { state: "loading" }
| { state: "error"; code?: string; retry: () => void } | { state: "error"; code?: string; retry: () => void }
| AccountPageEmptyViewModel | AccountPageEmptyViewModel
| AccountPageLoadedViewModel; | AccountPageLoadedViewModel;
export function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, { const operations = useBrokerageOperations(accountId, {
@@ -153,7 +153,7 @@ function toRecentEvents(
return { state: "loaded", to, items }; return { state: "loaded", to, items };
} }
export function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
const denominators = new Map<string, number>(); const denominators = new Map<string, number>();
@@ -221,3 +221,12 @@ function toPosition(
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null, logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
}; };
} }
export {
type AccountPagePosition,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toAccountPagePositionSections,
useAccountPageViewModel,
};
@@ -1,10 +1,17 @@
import { Stack, Text, Title } from "@mantine/core"; import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account"; import { accountTypeKey } from "@/entities/brokerage-account";
export function AccountHeading({ account }: { account?: BrokerageAccount }) { type AccountHeadingProps = {
account?: BrokerageAccount;
};
const AccountHeading: FC<AccountHeadingProps> = (props) => {
const { account } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack gap={2}> <Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title> <Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
@@ -15,4 +22,6 @@ export function AccountHeading({ account }: { account?: BrokerageAccount }) {
) : null} ) : null}
</Stack> </Stack>
); );
} };
export { AccountHeading };
@@ -1,13 +1,16 @@
import { Paper, Text } from "@mantine/core"; import { Paper, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio"; import { formatMoney } from "@/entities/portfolio";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
export function CashBalances({ type CashBalancesProps = {
balances,
}: {
balances: Portfolio["cashBalances"]; balances: Portfolio["cashBalances"];
}) { };
const CashBalances: FC<CashBalancesProps> = (props) => {
const { balances } = props;
return ( return (
<> <>
{balances.map((balance) => ( {balances.map((balance) => (
@@ -18,4 +21,6 @@ export function CashBalances({
))} ))}
</> </>
); );
} };
export { CashBalances };
@@ -1,17 +1,18 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function PortfolioRefreshState({ type PortfolioRefreshStateProps = {
refreshing,
refreshError,
retry,
}: {
refreshing: boolean; refreshing: boolean;
refreshError: boolean; refreshError: boolean;
retry?: () => void; retry?: () => void;
}) { };
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (!refreshing && !refreshError) return null; if (!refreshing && !refreshError) return null;
return ( return (
<> <>
{refreshing ? ( {refreshing ? (
@@ -34,4 +35,6 @@ export function PortfolioRefreshState({
) : null} ) : null}
</> </>
); );
} };
export { PortfolioRefreshState };
@@ -1,13 +1,16 @@
import { Paper, SimpleGrid, Text } from "@mantine/core"; import { Paper, SimpleGrid, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio"; import { formatMoney } from "@/entities/portfolio";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
export function PortfolioTotals({ type PortfolioTotalsProps = {
totals,
}: {
totals: Portfolio["totalsByCurrency"]; totals: Portfolio["totalsByCurrency"];
}) { };
const PortfolioTotals: FC<PortfolioTotalsProps> = (props) => {
const { totals } = props;
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{totals.map((total) => ( {totals.map((total) => (
@@ -22,4 +25,6 @@ export function PortfolioTotals({
))} ))}
</SimpleGrid> </SimpleGrid>
); );
} };
export { PortfolioTotals };
@@ -1,17 +1,18 @@
import { Card, Group, Stack, Text } from "@mantine/core"; import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/entities/portfolio"; import { formatMoney } from "@/entities/portfolio";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import type { AccountPagePosition } from "../AccountPage.vm"; import type { AccountPagePosition } from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view"; import { PositionLogo } from "./PositionLogo.view";
export function PositionCard({ type PositionCardProps = {
position,
wide = false,
}: {
position: AccountPagePosition; position: AccountPagePosition;
wide?: boolean; wide?: boolean;
}) { };
const PositionCard: FC<PositionCardProps> = (props) => {
const { position, wide = false } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const returnClass = position.returnTone const returnClass = position.returnTone
? classes[position.returnTone] ? classes[position.returnTone]
@@ -78,4 +79,6 @@ export function PositionCard({
) : null} ) : null}
</Card> </Card>
); );
} };
export { PositionCard };
@@ -1,14 +1,16 @@
import { Avatar } from "@mantine/core"; import { Avatar } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
export function PositionLogo({ type PositionLogoProps = {
src,
label,
}: {
src: string | null; src: string | null;
label: string; label: string;
}) { };
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { src, label } = props;
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (src && !failed) if (src && !failed)
return ( return (
<Avatar <Avatar
@@ -24,4 +26,6 @@ export function PositionLogo({
{(label.trim()[0] ?? "?").toUpperCase()} {(label.trim()[0] ?? "?").toUpperCase()}
</Avatar> </Avatar>
); );
} };
export { PositionLogo };
@@ -1,15 +1,17 @@
import { SimpleGrid } from "@mantine/core"; import { SimpleGrid } from "@mantine/core";
import type { FC } from "react";
import type { AccountPagePositionSection } from "../AccountPage.vm"; import type { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard.view"; import { PositionCard } from "./PositionCard.view";
import { Section } from "./Section.view"; import { Section } from "./Section.view";
export function PositionSection({ type PositionSectionProps = {
title,
section,
}: {
title: string; title: string;
section: AccountPagePositionSection; section: AccountPagePositionSection;
}) { };
const PositionSection: FC<PositionSectionProps> = (props) => {
const { title, section } = props;
return ( return (
<Section title={title}> <Section title={title}>
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}> <SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
@@ -23,4 +25,6 @@ export function PositionSection({
</SimpleGrid> </SimpleGrid>
</Section> </Section>
); );
} };
export { PositionSection };
@@ -1,4 +1,5 @@
import { Alert, Button, Paper, Text } from "@mantine/core"; import { Alert, Button, Paper, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event"; import type { BrokerageEvent } from "@/entities/brokerage-event";
@@ -7,8 +8,18 @@ import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm"; import type { AccountPageRecentEvents } from "../AccountPage.vm";
import { Section } from "./Section.view"; import { Section } from "./Section.view";
export function RecentEvents({ events }: { events: AccountPageRecentEvents }) { type RecentEventsProps = {
events: AccountPageRecentEvents;
};
type RecentEventProps = {
event: BrokerageEvent;
};
const RecentEvents: FC<RecentEventsProps> = (props) => {
const { events } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Section title={t("events.recentTitle")}> <Section title={t("events.recentTitle")}>
{events.state === "loading" ? ( {events.state === "loading" ? (
@@ -33,10 +44,12 @@ export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
</Button> </Button>
</Section> </Section>
); );
} };
function RecentEvent({ event }: { event: BrokerageEvent }) { const RecentEvent: FC<RecentEventProps> = (props) => {
const { event } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Paper className={classes.cash} withBorder> <Paper className={classes.cash} withBorder>
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text> <Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
@@ -47,4 +60,6 @@ function RecentEvent({ event }: { event: BrokerageEvent }) {
</Text> </Text>
</Paper> </Paper>
); );
} };
export { RecentEvents };
@@ -1,4 +1,5 @@
import { Alert, Button, Paper, Text } from "@mantine/core"; import { Alert, Button, Paper, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatMoney } from "@/entities/portfolio"; import { formatMoney } from "@/entities/portfolio";
@@ -6,12 +7,14 @@ import classes from "../AccountPage.module.css";
import type { AccountPageRecentOperations } from "../AccountPage.vm"; import type { AccountPageRecentOperations } from "../AccountPage.vm";
import { Section } from "./Section.view"; import { Section } from "./Section.view";
export function RecentOperations({ type RecentOperationsProps = {
operations,
}: {
operations: AccountPageRecentOperations; operations: AccountPageRecentOperations;
}) { };
const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { operations } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Section title={t("ledger.recentTitle")}> <Section title={t("ledger.recentTitle")}>
{operations.state === "loading" ? ( {operations.state === "loading" ? (
@@ -40,4 +43,6 @@ export function RecentOperations({
</Button> </Button>
</Section> </Section>
); );
} };
export { RecentOperations };
@@ -1,16 +1,19 @@
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
export function Section({ type SectionProps = PropsWithChildren<{
title,
children,
}: {
title: string; title: string;
children: React.ReactNode; }>;
}) {
const Section: FC<SectionProps> = (props) => {
const { title, children } = props;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
{children} {children}
</Stack> </Stack>
); );
} };
export { Section };
+5 -3
View File
@@ -1,3 +1,4 @@
import type { FC } from "react";
import { import {
AccountsPageEmpty, AccountsPageEmpty,
AccountsPageError, AccountsPageError,
@@ -6,7 +7,7 @@ import {
} from "./AccountsPage.view"; } from "./AccountsPage.view";
import { useAccountsPageViewModel } from "./AccountsPage.vm"; import { useAccountsPageViewModel } from "./AccountsPage.vm";
export function AccountsPage() { const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel(); const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />; if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />; if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
@@ -26,10 +27,11 @@ export function AccountsPage() {
retry={viewModel.retry} retry={viewModel.retry}
/> />
); );
} };
export { export {
AccountsPage,
AccountsPageEmpty, AccountsPageEmpty,
AccountsPageError, AccountsPageError,
AccountsPageLoading, AccountsPageLoading,
} from "./AccountsPage.view"; };
+50 -42
View File
@@ -7,14 +7,40 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account"; import { BrokerageAccountList } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
export function AccountsPageLoading() { type AccountsPageErrorProps = {
code?: string;
retry?: () => void;
};
type AccountsPageEmptyProps = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsPageLoadedProps = {
accounts: BrokerageAccount[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsRefreshStateProps = {
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
};
const AccountsPageLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
loading loading
@@ -22,15 +48,10 @@ export function AccountsPageLoading() {
text={t("states.loadingText")} text={t("states.loadingText")}
/> />
); );
} };
export function AccountsPageError({ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
code, const { code, retry } = props;
retry,
}: {
code?: string;
retry?: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const settingsAction = ( const settingsAction = (
<Button component={Link} to="/settings" variant="light"> <Button component={Link} to="/settings" variant="light">
@@ -77,18 +98,12 @@ export function AccountsPageError({
action={<Button onClick={retry}>{t("accounts.retry")}</Button>} action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/> />
); );
} };
export function AccountsPageEmpty({ const AccountsPageEmpty: FC<AccountsPageEmptyProps> = (props) => {
refreshing = false, const { refreshing = false, refreshError = false, retry } = props;
refreshError = false,
retry,
}: {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<AccountsRefreshState <AccountsRefreshState
@@ -102,20 +117,12 @@ export function AccountsPageEmpty({
/> />
</Stack> </Stack>
); );
} };
export function AccountsPageLoaded({ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
accounts, const { accounts, refreshing = false, refreshError = false, retry } = props;
refreshing = false,
refreshError = false,
retry,
}: {
accounts: BrokerageAccount[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Title order={1}>{t("accounts.title")}</Title> <Title order={1}>{t("accounts.title")}</Title>
@@ -127,19 +134,13 @@ export function AccountsPageLoaded({
<BrokerageAccountList accounts={accounts} /> <BrokerageAccountList accounts={accounts} />
</Stack> </Stack>
); );
} };
function AccountsRefreshState({ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
refreshing, const { refreshing, refreshError, retry } = props;
refreshError,
retry,
}: {
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!refreshing && !refreshError) return null; if (!refreshing && !refreshError) return null;
return ( return (
<> <>
{refreshing ? ( {refreshing ? (
@@ -162,4 +163,11 @@ function AccountsRefreshState({
) : null} ) : null}
</> </>
); );
} };
export {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
};
+3 -1
View File
@@ -1,6 +1,6 @@
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account"; import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
export function useAccountsPageViewModel() { function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
if (!accounts.data) { if (!accounts.data) {
if (accounts.isFetching) return { state: "loading" as const }; if (accounts.isFetching) return { state: "loading" as const };
@@ -26,3 +26,5 @@ export function useAccountsPageViewModel() {
retry: () => accounts.refetch(), retry: () => accounts.refetch(),
}; };
} }
export { useAccountsPageViewModel };
+6 -2
View File
@@ -1,9 +1,11 @@
import { Paper, Stack, Title } from "@mantine/core"; import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoginForm } from "@/features/auth"; import { LoginForm } from "@/features/auth";
export function LoginPage() { const LoginPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<main className="auth-page"> <main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder> <Paper className="auth-card" p="xl" radius="lg" withBorder>
@@ -14,4 +16,6 @@ export function LoginPage() {
</Paper> </Paper>
</main> </main>
); );
} };
export { LoginPage };
+6 -2
View File
@@ -1,10 +1,12 @@
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { Button, StateView } from "@/shared/ui"; import { Button, StateView } from "@/shared/ui";
export function NotFoundPage() { const NotFoundPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Title order={1}>{t("notFound.title")}</Title> <Title order={1}>{t("notFound.title")}</Title>
@@ -19,4 +21,6 @@ export function NotFoundPage() {
/> />
</Stack> </Stack>
); );
} };
export { NotFoundPage };
+6 -2
View File
@@ -1,9 +1,11 @@
import { Paper, Stack, Title } from "@mantine/core"; import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RegisterForm } from "@/features/auth"; import { RegisterForm } from "@/features/auth";
export function RegisterPage() { const RegisterPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<main className="auth-page"> <main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder> <Paper className="auth-card" p="xl" radius="lg" withBorder>
@@ -14,4 +16,6 @@ export function RegisterPage() {
</Paper> </Paper>
</main> </main>
); );
} };
export { RegisterPage };
+6 -2
View File
@@ -1,13 +1,15 @@
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection"; import { TbankConnectionSection } from "@/features/tbank-connection";
export function SettingsPage() { const SettingsPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { logout } = useAuth(); const { logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Stack> <Stack>
<Title order={1}>{t("settings.title")}</Title> <Title order={1}>{t("settings.title")}</Title>
@@ -22,4 +24,6 @@ export function SettingsPage() {
</Button> </Button>
</Stack> </Stack>
); );
} };
export { SettingsPage };
+12 -6
View File
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import type { ReactNode } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
@@ -10,11 +10,17 @@ import { apiQueryClient, configureAuthClient } from "./client";
function createWrapper() { function createWrapper() {
const client = new QueryClient(); const client = new QueryClient();
return ({ children }: { children: ReactNode }) => ( const TestWrapper: FC<PropsWithChildren> = (props) => {
<MantineProvider> const { children } = props;
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider> return (
); <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
} }
describe("useAuthenticatedQuery", () => { describe("useAuthenticatedQuery", () => {
+3 -1
View File
@@ -10,7 +10,7 @@ import {
subscribeAuthenticatedSession, subscribeAuthenticatedSession,
} from "./client"; } from "./client";
export function useAuthenticatedQuery< function useAuthenticatedQuery<
TQueryFnData = unknown, TQueryFnData = unknown,
TError = Error, TError = Error,
TData = TQueryFnData, TData = TQueryFnData,
@@ -32,3 +32,5 @@ export function useAuthenticatedQuery<
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)), (typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
}); });
} }
export { useAuthenticatedQuery };
+13 -5
View File
@@ -21,16 +21,16 @@ function setAuthenticatedSession(next: boolean) {
for (const listener of authSessionListeners) listener(); for (const listener of authSessionListeners) listener();
} }
export function configureAuthClient(handlers: AuthHandlers | null) { function configureAuthClient(handlers: AuthHandlers | null) {
auth = handlers; auth = handlers;
setAuthenticatedSession(Boolean(auth?.accessToken())); setAuthenticatedSession(Boolean(auth?.accessToken()));
} }
export function getAuthenticatedSessionSnapshot() { function getAuthenticatedSessionSnapshot() {
return hasAuthenticatedSession; return hasAuthenticatedSession;
} }
export function subscribeAuthenticatedSession(listener: () => void) { function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener); authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener); return () => authSessionListeners.delete(listener);
} }
@@ -71,10 +71,18 @@ async function authenticatedFetch(request: Request): Promise<Response> {
: response; : response;
} }
export const apiClient = createClient<paths>({ const apiClient = createClient<paths>({
baseUrl: apiBaseUrl, baseUrl: apiBaseUrl,
credentials: "include", credentials: "include",
fetch: authenticatedFetch, fetch: authenticatedFetch,
}); });
export const apiQueryClient = createQueryClient(apiClient); const apiQueryClient = createQueryClient(apiClient);
export {
apiClient,
apiQueryClient,
configureAuthClient,
getAuthenticatedSessionSnapshot,
subscribeAuthenticatedSession,
};
+3 -1
View File
@@ -1,4 +1,4 @@
export const ru = { const ru = {
translation: { translation: {
appName: "WealthLedger", appName: "WealthLedger",
navigation: { accounts: "Счета", settings: "Настройки" }, navigation: { accounts: "Счета", settings: "Настройки" },
@@ -167,3 +167,5 @@ export const ru = {
}, },
}, },
} as const; } as const;
export { ru };
+3 -1
View File
@@ -2,8 +2,10 @@ import type { ParseKeys } from "i18next";
import { i18n } from "./i18n"; import { i18n } from "./i18n";
export function validationMessage( function validationMessage(
key: ParseKeys, key: ParseKeys,
): (_value: unknown) => { message: string } { ): (_value: unknown) => { message: string } {
return () => ({ message: i18n.t(key) }); return () => ({ message: i18n.t(key) });
} }
export { validationMessage };
+9 -6
View File
@@ -1,13 +1,14 @@
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core"; import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router"; import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
export function AppShell({ type AppShellProps = {
contentAboveOutlet,
}: {
contentAboveOutlet?: ReactNode; contentAboveOutlet?: ReactNode;
}) { };
const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const items = [ const items = [
@@ -52,4 +53,6 @@ export function AppShell({
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
} };
export { AppShell };
+5 -10
View File
@@ -1,5 +1,5 @@
import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core"; import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core";
import type { ReactNode } from "react"; import type { FC, ReactNode } from "react";
type StateViewProps = { type StateViewProps = {
title: string; title: string;
@@ -9,13 +9,8 @@ type StateViewProps = {
error?: boolean; error?: boolean;
}; };
export function StateView({ const StateView: FC<StateViewProps> = (props) => {
title, const { title, text, action, loading = false, error = false } = props;
text,
action,
loading = false,
error = false,
}: StateViewProps) {
const role = loading ? "status" : error ? "alert" : undefined; const role = loading ? "status" : error ? "alert" : undefined;
return ( return (
@@ -28,6 +23,6 @@ export function StateView({
</Stack> </Stack>
</Paper> </Paper>
); );
} };
export { Button }; export { Button, StateView };
+3 -1
View File
@@ -5,7 +5,7 @@ const authentication = {
user: { id: "user-1", email: "user@example.com" }, user: { id: "user-1", email: "user@example.com" },
}; };
export const handlers = [ const handlers = [
http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)), http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)),
http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)), http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)),
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
@@ -53,3 +53,5 @@ export const handlers = [
}), }),
), ),
]; ];
export { handlers };
+3 -1
View File
@@ -1,4 +1,6 @@
import { setupServer } from "msw/node"; import { setupServer } from "msw/node";
import { handlers } from "./handlers"; import { handlers } from "./handlers";
export const server = setupServer(...handlers); const server = setupServer(...handlers);
export { server };