refactor: simplify frontend and clarify module ownership #45

Merged
ksv741 merged 16 commits from codex/frontend-calendar-form-cleanup into develop 2026-10-01 21:07:51 +03:00
226 changed files with 4595 additions and 4632 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). [resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+8
View File
@@ -17,6 +17,10 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: ESLint и review ## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
Review проверяет то, чего синтаксис не доказывает: Review проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. - Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`. - Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток - Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
+11 -5
View File
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
в каталоге: её граф зависимостей не достигает application data/API. в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view. `useTranslation` и декларативный вывод коллекции допустимы во view.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
type imports, error helpers, router components и form context разрешены.
Транзитивный fetching через local presentation hook проверяется review;
Storybook дополнительно проверяет reachable graph. Новое исключение должно
сохранять presentation-only responsibility.
JSX props получают ссылки на функции, включая `onClick`, render props, JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай `dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием `useController` в VM вместо inline `Controller.render`. Значение prop с условием
@@ -76,9 +82,9 @@ JSX props получают ссылки на функции, включая `onC
Closure над состоянием, mutations и refs является действием VM, а не shared helper. Closure над состоянием, mutations и refs является действием VM, а не shared helper.
VM собирает связные предметные hooks, когда внутри смешиваются независимые VM собирает связные предметные hooks, когда внутри смешиваются независимые
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
возвращает данные и действия своей ответственности, VM объединяет их для view. простое подключение одного controller остаётся в VM формы.
Не группируй несвязанные функции только по префиксу `on*` и не создавай Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления. passthrough hook над одним вызовом ради формального дробления.
@@ -110,8 +116,8 @@ Render-helper замени самостоятельным presentation component
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css` лежит рядом со своим view, тестом и story. Формы используют Mantine
остаётся в `bank-card/ui`. `Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
## Формы ## Формы
+9
View File
@@ -1,5 +1,14 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{
from: {
path: "^(src|packages/[^/]+/src)/",
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
},
name: "production-must-not-depend-on-test-code",
severity: "error",
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
},
{ {
from: {}, from: {},
name: "no-unresolved", name: "no-unresolved",
+41
View File
@@ -153,11 +153,13 @@ export default defineConfig(
}, },
}, },
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error",
// React ignores event handler return values; floating promises are still checked in bodies. // React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
"error", "error",
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
@@ -183,6 +185,44 @@ export default defineConfig(
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
}, },
{
files: ["src/**/*.view.tsx"],
rules: {
"no-restricted-imports": ["error", {
paths: [
{
allowTypeImports: true,
message: "Query lifecycle belongs to the data VM.",
name: "@tanstack/react-query",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-fetch",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-react-query",
},
],
patterns: [
{
allowTypeImports: true,
group: ["react-router", "@/entities/*", "@/features/*"],
importNamePattern: "^use",
message: "Pass route, auth and entity data from the data VM.",
},
{
allowTypeImports: true,
group: ["@/shared/api", "@/shared/api/*"],
importNamePattern: "^use|^api(?:Query)?Client$",
message: "Fetching belongs to the data VM; error helpers are allowed.",
},
],
}],
},
},
{ {
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
@@ -197,6 +237,7 @@ export default defineConfig(
ignoreTypeIndexes: true, ignoreTypeIndexes: true,
}, },
], ],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
}, },
}, },
{ {
+21 -21
View File
@@ -4082,9 +4082,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": { "node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.4", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6942,9 +6942,9 @@
} }
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.3.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6969,9 +6969,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9668,9 +9668,9 @@
} }
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"devOptional": true, "devOptional": true,
"funding": [ "funding": [
{ {
@@ -10702,9 +10702,9 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.7.0", "version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14291,9 +14291,9 @@
} }
}, },
"node_modules/release-it/node_modules/undici": { "node_modules/release-it/node_modules/undici": {
"version": "7.29.0", "version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14673,9 +14673,9 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.1.1", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
+7 -1
View File
@@ -23,7 +23,7 @@
"openapi:check": "node scripts/check-openapi.mjs", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky", "prepare": "husky",
"preview": "vite preview", "preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
@@ -114,6 +114,12 @@
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.0"
}, },
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
},
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
"fsevents@2.3.3": true "fsevents@2.3.3": true
+1 -4
View File
@@ -1,15 +1,12 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": [ "lib": [
"ES2023", "ES2023",
"DOM" "DOM"
], ],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
@@ -0,0 +1,38 @@
import "@mantine/core/styles.css";
import "../styles.css";
import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { cssVariablesResolver, theme } from "../theme";
import { EntryCard } from "./EntryCard";
afterEach(cleanup);
it("shows the full wrapped title without horizontal overflow", () => {
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
render(
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
<EntryCard
initial="Д"
metadata="29.09.2026"
title={title}
tone="positive"
value="123 456 789,90 ₽"
valueLabel="Дивиденды"
wrap
/>
</MantineProvider>,
);
const description = screen.getByText(title);
expect(description.clientHeight).toBeGreaterThan(0);
expect(description.scrollHeight)
.toBeLessThanOrEqual(description.clientHeight);
expect(document.documentElement.scrollWidth)
.toBeLessThanOrEqual(window.innerWidth);
});
@@ -93,3 +93,13 @@
transition: none; transition: none;
} }
} }
.row[data-wrap] p {
white-space: normal;
overflow-wrap: anywhere;
}
@media (min-width: 36em) {
.row[data-wrap] {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
@@ -39,3 +39,5 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
+10 -2
View File
@@ -18,6 +18,7 @@ type EntryCardProps = {
| "positive"; | "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
@@ -27,11 +28,18 @@ const EntryCard: FC<EntryCardProps> = (props) => {
} }
: undefined; : undefined;
const titleLineClamp = props.wrap ? undefined : 1;
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return ( return (
<Paper className={classes.row} style={surfaceStyle} withBorder> <Paper
className={classes.row}
data-wrap={props.wrap || undefined}
style={surfaceStyle}
withBorder
>
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
@@ -43,7 +51,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={titleLineClamp}>
{props.title} {props.title}
</Text> </Text>
+1 -4
View File
@@ -1,13 +1,10 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
+55
View File
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
const eslint = new ESLint(); const eslint = new ESLint();
for (const [filePath, code, rule] of [ for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function read(value: string) { return value?.trim(); }",
"@typescript-eslint/no-unnecessary-condition",
],
[
"src/shared/api/problem-details.ts",
"export function read(value?: string) { return value!.trim(); }",
"@typescript-eslint/no-non-null-assertion",
],
[
"src/shared/api/problem-details.ts",
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
"@typescript-eslint/consistent-type-imports",
],
[ [
"src/shared/api/problem-details.ts", "src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }", "export function run() { Promise.resolve(); }",
@@ -323,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
assert.equal(again.errorCount, 0); assert.equal(again.errorCount, 0);
}); });
test("views accept presentation inputs but reject direct data hooks", async () => {
const eslint = new ESLint();
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
for (const code of [
"import { useStock } from \"@/entities/stock\";",
"import { useAuth } from \"@/features/auth\";",
"import { useNavigate } from \"react-router\";",
"import { apiClient } from \"@/shared/api\";",
"import { useQuery } from \"@tanstack/react-query\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.some(message =>
message.ruleId === "no-restricted-imports"));
}
for (const code of [
"import { Link } from \"react-router\";",
"import { apiErrorCode } from \"@/shared/api\";",
"import type { StockPosition } from \"@/entities/stock\";",
"import type { QueryKey } from \"@tanstack/react-query\";",
"import { useTranslation } from \"react-i18next\";",
"import { useFormContext } from \"react-hook-form\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
}
const [vm] = await eslint.lintText(
"import { useStock } from \"@/entities/stock\";",
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
);
assert.ok(vm.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
});
test("compact JSX and contract nulls need no local suppressions", async () => { test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
+1
View File
@@ -0,0 +1 @@
export { useCurrentRoute } from "./useCurrentRoute";
+23
View File
@@ -0,0 +1,23 @@
import { useMatch } from "react-router";
function useCurrentRoute() {
const account = useMatch("/accounts/:accountId/:section?");
const section = (["events", "income", "operations"] as const)
.find(value => value === account?.params.section?.toLowerCase());
return {
account,
accounts: useMatch("/accounts"),
bond: useMatch("/bonds/:bondId"),
bonds: useMatch("/bonds"),
card: useMatch("/cards/:cardId"),
cards: useMatch("/cards"),
section,
settings: useMatch("/settings"),
stock: useMatch("/stocks/:stockId"),
stocks: useMatch("/stocks"),
};
}
export { useCurrentRoute };
@@ -238,6 +238,34 @@ describe("All accounts routes", () => {
); );
}); });
it.each(["operations", "events", "income"])(
"restores applied dates when switching accounts in %s",
async (section) => {
const search = "?from=2026-08-01&to=2026-08-31";
renderPage(`/accounts/all/${section}${search}`);
fireEvent.click(await screen.findByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: /10 August 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /31 August 2026/i }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
"10.08.2026 – 31.08.2026",
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 31.08.2026",
),
);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/${section}${search}`,
);
},
);
it("groups the same security and links its accounts and overview sections", async () => { it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all"); renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income", "/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
); );
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
}); });
it("restarts an expired aggregate cursor without losing filters", async () => { it("restarts an expired aggregate cursor without losing filters", async () => {
+64 -7
View File
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { type InitialEntry, Link, MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
@@ -12,10 +14,11 @@ import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
function renderRoutes(path: string) { function renderRoutes(path: InitialEntry, controls?: ReactNode) {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
{controls}
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver} cssVariablesResolver={cssVariablesResolver}
theme={theme} theme={theme}
@@ -57,10 +60,20 @@ describe("AppRoutes", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("logs in through the public form and reaches the protected landing", async () => { it.each([
{ endpoint: "login", path: "/accounts", title: "Вход" },
{ endpoint: "register", path: "/register", title: "Регистрация" },
{
endpoint: "login",
path: { pathname: "/login", state: { returnTo: "//example.com" } },
title: "Вход",
},
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
let credentials: unknown; let credentials: unknown;
server.use( server.use(
http.post("*/api/v1/auth/login", async ({ request }) => { http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
credentials = await request.json(); credentials = await request.json();
return HttpResponse.json({ return HttpResponse.json({
@@ -69,16 +82,23 @@ describe("AppRoutes", () => {
}); });
}), }),
); );
renderRoutes("/accounts"); renderRoutes(path);
await screen.findByRole("heading", { level: 1, name: "Вход" }); await screen.findByRole("heading", { level: 1, name: title });
fireEvent.change(screen.getByLabelText("Email"), { fireEvent.change(screen.getByLabelText("Email"), {
target: { value: " User@Example.com " }, target: { value: " User@Example.com " },
}); });
fireEvent.change(screen.getByLabelText("Пароль"), { fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" }, target: { value: "a secure password" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
if (endpoint === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "a secure password" },
});
}
fireEvent.click(screen.getByRole("button", { name: title }));
expect( expect(
await screen.findByRole("heading", { await screen.findByRole("heading", {
@@ -93,6 +113,43 @@ describe("AppRoutes", () => {
}); });
}); });
it.each([
{ isFailure: false, isNewDestination: false },
{ isFailure: true, isNewDestination: false },
{ isFailure: true, isNewDestination: true },
])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => {
const { promise: pendingLogout, resolve: finishLogout }
= Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/auth/refresh", () => HttpResponse.json({
accessToken: "test-token", user: { email: "user@example.com", id: "user-1" },
})),
http.post("*/api/v1/auth/logout", async () => {
await pendingLogout;
return isFailure
? HttpResponse.error()
: new HttpResponse(null, { status: 204 });
}),
);
renderRoutes("/settings", <Link to="/settings?source=manual">Открыть настройки</Link>);
fireEvent.click(await screen.findByRole("button", { name: "Выйти" }));
await screen.findByRole("heading", { level: 1, name: "Вход" });
if (isNewDestination) fireEvent.click(screen.getByRole("link", { name: "Открыть настройки" }));
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" } });
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
finishLogout();
const destination = isNewDestination
? { level: 1, name: "Настройки" }
: { level: 2, name: "Нет инвестиционных счетов" };
expect(await screen.findByRole("heading", destination)).toBeInTheDocument();
expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger");
});
it("returns after login to cards with the selected month without T-Bank", async () => { it("returns after login to cards with the selected month without T-Bank", async () => {
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
-36
View File
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
path="/accounts" path="/accounts"
/> />
<Route
element={(
<LazyPage variant="content">
<AccountPage accountId="all" />
</LazyPage>
)}
path="/accounts/all"
/>
<Route
element={(
<LazyPage variant="content">
<AccountOperationsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/operations"
/>
<Route
element={(
<LazyPage variant="content">
<AccountIncomePage accountId="all" />
</LazyPage>
)}
path="/accounts/all/income"
/>
<Route
element={(
<LazyPage variant="content">
<AccountEventsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/events"
/>
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
+13 -9
View File
@@ -2,8 +2,8 @@ import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks"; import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -82,14 +82,18 @@ const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const ProtectedDocumentTitle: FC = () => { const ProtectedDocumentTitle: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useMatch("/accounts/:accountId/:section?");
const bond = useMatch("/bonds/:bondId"); const {
const stock = useMatch("/stocks/:stockId"); account,
const card = useMatch("/cards/:cardId"); bond,
const cards = useMatch("/cards"); bonds,
const bonds = useMatch("/bonds"); card,
const stocks = useMatch("/stocks"); cards,
const settings = useMatch("/settings"); settings,
stock,
stocks,
} = useCurrentRoute();
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />; if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
if (account) if (account)
return ( return (
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
return { hasError: true }; return { hasError: true };
} }
state: State = { hasError: false }; override state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) { override 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 });
} }
} }
render() { override render() {
return this.state.hasError return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} /> ? <RouteErrorFallback authenticated={this.props.authenticated} />
: ( : (
+5 -8
View File
@@ -1,6 +1,7 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Container, Group, Skeleton, Stack } from "@mantine/core"; import { Container, Group, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
if (variant === "auth") if (variant === "auth")
return ( return (
<Container py="xl" size={420}> <Container py="xl" size={420}>
<Stack <LoadingSkeleton label={t("states.loadingTitle")}>
aria-busy="true"
aria-label={t("states.loadingTitle")}
role="status"
>
<Skeleton height={32} width="45%" /> <Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
@@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" /> <Skeleton height={36} radius="sm" />
</Stack> </LoadingSkeleton>
</Container> </Container>
); );
@@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
); );
return ( return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status"> <LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </LoadingSkeleton>
); );
}; };
+14 -6
View File
@@ -1,5 +1,4 @@
import type { FC, PropsWithChildren } from "react"; import { type FC, type PropsWithChildren, useState } from "react";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { ready, user } = useAuth(); const { ready, signedOut, user } = useAuth();
const location = useLocation(); const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const loginState = signedOut && wasAuthenticated
? undefined
: { returnTo: `${location.pathname}${location.search}` };
return user return user
? <>{children}</> ? <>{children}</>
: ( : (
<Navigate <Navigate
replace replace
state={{ returnTo: `${location.pathname}${location.search}` }} state={loginState}
to="/login" to="/login"
/> />
); );
@@ -30,8 +35,11 @@ const Public: FC<PropsWithChildren> = (props) => {
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null const state: unknown = location.state;
| { returnTo?: string })?.returnTo;
const requested = state && typeof state === "object" && "returnTo" in state
? state.returnTo
: undefined;
const returnTo const returnTo
= typeof requested === "string" = typeof requested === "string"
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
expect(screen.queryByRole("link")).not.toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument();
}); });
it("links to the account hierarchy from operations", () => { it.each([
renderBreadcrumbs("/accounts/account-1/operations", [ ["operations", "Операции"],
["OPERATIONS", "Операции"],
["events", "Будущие события"],
["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [
{ id: "account-1", name: "ИИС" }, { id: "account-1", name: "ИИС" },
]); ]);
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
"href", "href",
"/accounts/account-1", "/accounts/account-1",
); );
expect(screen.getByText("Операции")).toHaveAttribute( expect(screen.getByText(title)).toHaveAttribute(
"aria-current", "aria-current",
"page", "page",
); );
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
expect(screen.queryByText("account-1")).not.toBeInTheDocument(); expect(screen.queryByText("account-1")).not.toBeInTheDocument();
}); });
it.each(["/login", "/unknown"])("does not render on %s", (path) => { it.each([
"/login",
"/unknown",
"/accounts/account-1/unknown",
])("does not render on %s", (path) => {
renderBreadcrumbs(path); renderBreadcrumbs(path);
expect( expect(
+17 -56
View File
@@ -2,7 +2,8 @@ import type { FC } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
@@ -14,74 +15,34 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const operations = useMatch({ const {
end: true, account,
path: "/accounts/:accountId/operations", accounts,
}); bond,
card,
cards,
section,
settings,
stock,
} = useCurrentRoute();
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" });
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const accounts = useMatch({ end: true, path: "/accounts" });
const settings = useMatch({ end: true, path: "/settings" });
const stock = useMatch({ end: true, path: "/stocks/:stockId" });
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />; if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (operations) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
<AccountBreadcrumbs <AccountBreadcrumbs
accountId={operations.params.accountId ?? ""} accountId={account.params.accountId ?? ""}
section="operations" section={section}
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={operations.params.accountId ?? ""} accountId={account.params.accountId ?? ""}
section="operations" section={section}
/> />
</Stack> </Stack>
); );
if (events)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
/>
</Stack>
);
if (income)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
/>
</Stack>
);
if (account)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />; if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
+24 -20
View File
@@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard, CreateBankOperation } from "./types";
import { import {
useCreateBankOperation, useCreateBankOperation,
useDeleteBankOperation, useDeleteBankOperation,
@@ -14,8 +16,6 @@ import {
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "./mutations"; } from "./mutations";
import { import {
type BankCard,
type CreateBankOperation,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -106,14 +106,14 @@ function useReadModels() {
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20, from: "2026-09-01",
month: "2026-09", limit: 20, page: 2,
page: 2, to: "2026-09-30",
}); });
const expenses = useBankExpenses("a", { page: 1 }); const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09"); const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
const august = useBankSummary("2026-08", "b"); const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
@@ -144,7 +144,8 @@ describe("banking API", () => {
const page = Number(url.searchParams.get("page")); const page = Number(url.searchParams.get("page"));
pages.push(page); pages.push(page);
expect(url.searchParams.get("limit")).toBe("100"); expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2) if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 }); return HttpResponse.json({ code: "FAILED" }, { status: 500 });
@@ -158,7 +159,7 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -171,7 +172,7 @@ describe("banking API", () => {
isFail = true; isFail = true;
const { result: failedResult } = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -179,7 +180,7 @@ describe("banking API", () => {
expect(failedResult.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use(...readHandlers(counts)); server.use(...readHandlers(counts));
@@ -191,12 +192,15 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
expect(counts.keys()).toContain( const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
); const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
expect(counts.keys()).toContain( expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
"/api/v1/bank-cards/summary?cardId=b&month=2026-08", from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
); });
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
cardId: "b", from: "2026-08-01", to: "2026-08-31",
});
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
}); });
@@ -303,7 +307,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100"); expect(result.current.query.data?.balance.value).toBe("100");
}); });
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use( server.use(
...readHandlers(counts), ...readHandlers(counts),
@@ -403,7 +407,7 @@ describe("banking API", () => {
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0]?.name).toBe("Food"),
); );
await act(() => await act(() =>
result.current.updateCategory.mutateAsync({ result.current.updateCategory.mutateAsync({
@@ -412,7 +416,7 @@ describe("banking API", () => {
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Groceries"), expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
); );
await act(() => await act(() =>
result.current.updateOperation.mutateAsync({ result.current.updateOperation.mutateAsync({
+11 -11
View File
@@ -14,7 +14,7 @@ import type {
UpdateBankCard, UpdateBankCard,
UpdateBankOperation, UpdateBankOperation,
UpdateExpenseCategory, UpdateExpenseCategory,
} from "./queries"; } from "./types";
async function mutationData<T>( async function mutationData<T>(
request: Promise<{ data?: T; error?: unknown }>, request: Promise<{ data?: T; error?: unknown }>,
@@ -46,16 +46,16 @@ function useBankMutationOptions() {
return { return {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
if (
[ return [
ApiErrorCode.BankDependentRefunds, ApiErrorCode.BankDependentRefunds,
ApiErrorCode.BankIntentConflict, ApiErrorCode.BankIntentConflict,
ApiErrorCode.BankRefundLimit, ApiErrorCode.BankRefundLimit,
ApiErrorCode.BankVersionConflict, ApiErrorCode.BankVersionConflict,
ApiErrorCode.BankWriteConflict, ApiErrorCode.BankWriteConflict,
].some(code => isApiErrorCode(errorCode, code)) ].some(code => isApiErrorCode(errorCode, code))
) ? invalidate()
return invalidate(); : undefined;
}, },
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
+7 -57
View File
@@ -1,44 +1,12 @@
import { import {
apiClient, apiClient,
apiQueryClient, apiQueryClient,
type paths,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "@/shared/api"; } from "@/shared/api";
import { moneyCents } from "../model/form-validation"; import type { BankOperation, BankOperations, BankSummary } from "./types";
type BankCard import { moneyCents } from "../model/money";
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100; const OPERATIONS_PAGE_SIZE = 100;
@@ -110,7 +78,6 @@ function useBankOperations(
query: { query: {
from?: string; from?: string;
limit?: "all" | number; limit?: "all" | number;
month?: string;
page: number; page: number;
to?: string; to?: string;
}, },
@@ -134,7 +101,7 @@ function useBankOperations(
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({ return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))), enabled: Boolean(id && query.from && query.to),
placeholderData: ( placeholderData: (
previousData: BankOperations | undefined, previousData: BankOperations | undefined,
previousQuery, previousQuery,
@@ -143,8 +110,7 @@ function useBankOperations(
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id && previous?.path.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from && previous?.query?.from === query.from
&& previous?.query?.to === query.to && previous?.query?.to === query.to
&& previous?.query?.limit === query.limit && previous?.query?.limit === query.limit
@@ -165,6 +131,7 @@ function useBankOperations(
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract.
if (!data) throw new Error("Missing bank operations response"); if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data; if (!isAll) return data;
items.push(...data.items); items.push(...data.items);
@@ -178,36 +145,19 @@ function useBankOperations(
} }
function useBankSummary( function useBankSummary(
period: string period: { from: string; to: string },
| { from: string; to: string },
cardId?: string, cardId?: string,
) { ) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query }, params: { query: { ...period, cardId } },
}), }),
enabled: Boolean(period),
select: sortBankSummary, select: sortBankSummary,
}); });
} }
export { export {
type BankCard,
type BankOperation,
type BankSummary,
type CreateBankCard,
type CreateBankOperation,
type CreateExpenseCategory,
type ExpenseCategory,
sortBankSummary, sortBankSummary,
type UpdateBankCard,
type UpdateBankOperation,
type UpdateExpenseCategory,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
+3 -1
View File
@@ -1,6 +1,8 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries"; import type { BankSummary } from "./types";
import { sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value }); const money = (value: string) => ({ currency: "RUB", value });
+48
View File
@@ -0,0 +1,48 @@
import type { paths } from "@/shared/api";
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
export type {
BankCard,
BankOperation,
BankOperations,
BankSummary,
CreateBankCard,
CreateBankOperation,
CreateExpenseCategory,
ExpenseCategory,
UpdateBankCard,
UpdateBankOperation,
UpdateExpenseCategory,
};
+4 -4
View File
@@ -5,8 +5,6 @@ export {
useUpdateBankOperation, useUpdateBankOperation,
} from "./api/mutations"; } from "./api/mutations";
export { export {
type BankCard,
type BankOperation,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -15,14 +13,16 @@ export {
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
} from "./api/queries"; } from "./api/queries";
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; export type { BankCard, BankOperation } from "./api/types";
export { bankToday } from "./model/form-validation";
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
export { bankMoneyValue, moneyCents } from "./model/money";
export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationAppearance } from "./model/operation-appearance";
export { bankOperationKinds } from "./model/operation-kinds"; export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period"; export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { bankDateField } from "./ui/bankDateField";
export { BankFeedback } from "./ui/BankFeedback"; export { BankFeedback } from "./ui/BankFeedback";
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
@@ -2,9 +2,11 @@ import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/queries"; import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80; const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) { function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({ return z.object({
name: z name: z
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
}); });
} }
export { categorySchema }; export { categorySchema, type CategoryValues };
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; import {
bankCardSchema,
bankDateSchema,
positiveMoneySchema,
} from "./form.schema";
import { bankMoneyValue } from "./money";
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
describe("bank form input", () => { describe("bank form input", () => {
@@ -51,6 +56,9 @@ describe("bank form input", () => {
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false);
expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true);
expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true);
}); });
}); });
@@ -1,5 +1,4 @@
const DECIMAL_PART_COUNT = 2; import { moscowDate } from "@/shared/lib";
const CENTS_PRECISION = 2;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
@@ -7,13 +6,6 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
} }
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) { function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value ? value
@@ -21,27 +13,7 @@ function bankMonth(value: null | string) {
} }
function bankToday() { function bankToday() {
return new Intl.DateTimeFormat("en-CA", { return moscowDate(new Date());
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).format(new Date());
} }
function moneyCents(value: string): bigint | null { export { bankDateInputValue, bankMonth, bankToday };
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
+4 -6
View File
@@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { calendarDate } from "@/shared/lib";
import { bankToday, moneyCents } from "./form-validation"; import { bankToday } from "./form-validation";
const ISO_DATE_LENGTH = 10; import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -34,11 +35,8 @@ const bankDateSchema = z
.string() .string()
.refine( .refine(
value => value =>
/^\d{4}-\d{2}-\d{2}$/.test(value) calendarDate.safeParse(value).success
&& value >= "0001-01-01" && value >= "0001-01-01"
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH)
=== value
&& value <= bankToday(), && value <= bankToday(),
{ error: validationMessage("bank.validation.date") }, { error: validationMessage("bank.validation.date") },
); );
+26
View File
@@ -0,0 +1,26 @@
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankMoneyValue, moneyCents };
@@ -1,6 +1,6 @@
import type { OperationIconProps } from "@wealthledger/icons"; import type { OperationIconProps } from "@wealthledger/icons";
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const categoryIcons: { const categoryIcons: {
appearance: OperationIconProps; appearance: OperationIconProps;
@@ -1,4 +1,4 @@
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const bankOperationKinds = [ const bankOperationKinds = [
"expense", "expense",
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import classes from "./BankCardFace.module.css"; import classes from "./BankCardFace.module.css";
@@ -1,10 +1,9 @@
import { useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema"; import type { BankCardValues } from "../../model/form.schema";
import { useOpeningDateField } from "./useOpeningDateField"; import { bankDateField } from "../bankDateField";
import { usePaymentSystemField } from "./usePaymentSystemField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
const { const {
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
} = useFormContext<BankCardValues>(); } = useFormContext<BankCardValues>();
const { t } = useTranslation(); const { t } = useTranslation();
const openingDate = useOpeningDateField(control); const { field: openingDateField } = useController({ control, name: "openingDate" });
const paymentSystem = usePaymentSystemField(control); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
return { ...openingDate, ...paymentSystem, errors, register, t }; const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return {
errors,
onPaymentSystemChange,
openingDate: { ...openingDateField, ...bankDateField(openingDateField) },
paymentSystemField,
paymentSystems,
register,
t,
};
} }
export { useBankCardFieldsVm }; export { useBankCardFieldsVm };
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankCardForm } from "./BankCardForm"; import { BankCardForm } from "./BankCardForm";
@@ -1,12 +1,12 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC } from "react"; import { type FC } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { type BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm"; import { useBankCardFormVm } from "./BankCardForm.vm";
@@ -40,7 +40,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} /> <BankCardFields archived={Boolean(props.card?.archivedAt)} />
@@ -80,7 +80,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => { const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const { const {
dateParser,
errors, errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange, onPaymentSystemChange,
openingDateField, openingDate,
openingDateValue,
paymentSystemField, paymentSystemField,
paymentSystems, paymentSystems,
register, register,
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
/> />
<DateInput <DateInput
{...openingDateField} {...openingDate}
dateParser={dateParser}
error={errors.openingDate?.message} error={errors.openingDate?.message}
fixOnBlur={false} fixOnBlur={false}
label={t("bank.openingDate")} label={t("bank.openingDate")}
maxDate={bankToday()} maxDate={bankToday()}
onBlur={onOpeningDateBlur}
onChange={onOpeningDateChange}
onInput={onOpeningDateInput}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
withinPortal: false, withinPortal: false,
}} }}
readOnly={props.archived} readOnly={props.archived}
required required
value={openingDateValue}
valueFormat="DD.MM.YYYY" valueFormat="DD.MM.YYYY"
/> />
@@ -3,10 +3,13 @@ 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";
import type { BankCard } from "../../api/types";
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations"; import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries"; import { useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation"; import { bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
import { bankMoneyValue } from "../../model/money";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void }; type BankCardFormProps = { card?: BankCard; onClose: () => void };
@@ -1,49 +0,0 @@
import type { ComponentProps } from "react";
import type { Control } from "react-hook-form";
import { DateInput } from "@mantine/dates";
import { useController } from "react-hook-form";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useOpeningDateField(control: Control<BankCardValues>) {
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null;
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange = (value: null | string) =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
openingDateField,
openingDateValue,
};
}
export { useOpeningDateField };
@@ -1,27 +0,0 @@
import type { Control } from "react-hook-form";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
function usePaymentSystemField(control: Control<BankCardValues>) {
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
}
export { usePaymentSystemField };
@@ -1,6 +0,0 @@
.fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
@@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) {
onChange(next); onChange(next);
}; };
return { onChange, onPeriodChange, t, value }; return { onPeriodChange, t, value };
} }
export { useBankPeriodPickerVm }; export { useBankPeriodPickerVm };
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries"; import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
@@ -78,6 +78,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
</LoadingSkeleton> </LoadingSkeleton>
); );
} }
return null;
}; };
const BankSummaryView: FC<BankSummaryViewProps> = (props) => { const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
@@ -8,7 +8,7 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
name: "Переименовать", name: "Переименовать",
}); });
assert.isDefined(renameButtons[0]);
fireEvent.click(renameButtons[0]); fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }), await screen.findByRole("button", { name: "Перечитать запись" }),
); );
await waitFor(() => expect(reads).toBe(3)); await waitFor(() => expect(reads).toBe(3));
fireEvent.click( const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
screen.getAllByRole("button", { name: "Переименовать" })[1], assert.isDefined(secondRename);
); fireEvent.click(secondRename);
fireEvent.change(screen.getByLabelText(/Название категории/), { fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" }, target: { value: "Новая вторая" },
}); });
@@ -1,6 +1,6 @@
import { import { Badge,
Badge,
Button, Button,
Fieldset,
Group, Group,
Modal, Modal,
Stack, Stack,
@@ -12,17 +12,14 @@ import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form"; import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css"; import {
import { useCategoryManagerVm } from "./CategoryManager.vm"; type CategoryManagerProps,
useCategoryManagerVm,
type CategoryManagerProps = { } from "./CategoryManager.vm";
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => { const CategoryFields: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -76,7 +73,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
{categoryRows?.map((category) => { {categoryRows?.map((category) => {
return ( return (
@@ -149,7 +146,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -5,12 +5,14 @@ import { useRef, 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";
import type { ExpenseCategory } from "../../api/types";
import { import {
useCreateExpenseCategory, useCreateExpenseCategory,
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "../../api/mutations"; } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries"; import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = { type CategoryManagerProps = {
@@ -18,8 +20,6 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
}; };
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) { function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus(); useBankFormFocus();
const [opened, setOpened] = useState(true); const [opened, setOpened] = useState(true);
@@ -41,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting; const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false); const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => { void methods.handleSubmit(async (values) => {
setError(undefined); setError(undefined);
@@ -59,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} }
catch (error_) { catch (error_) {
setError(error_); setError(error_);
@@ -88,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
const onCancel = () => { const onCancel = () => {
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined); setError(undefined);
}; };
@@ -129,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
} }
export { useCategoryManagerVm }; export { type CategoryManagerProps, useCategoryManagerVm };
@@ -0,0 +1,39 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import { bankDateInputValue } from "../model/form-validation";
import { bankDateSchema } from "../model/form.schema";
type DateField = {
onBlur: () => void;
onChange: (value: string) => void;
value: string;
};
function bankDateField(field: DateField) {
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = (value) => {
const parsed = bankDateInputValue(value);
return bankDateSchema.safeParse(parsed).success ? parsed : null;
};
const onBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
};
const onInput: ComponentProps<typeof DateInput>["onInput"] = event =>
field.onChange(bankDateInputValue(event.currentTarget.value));
return {
dateParser,
onBlur,
onChange: (value: null | string) => field.onChange(value ?? ""),
onInput,
value: bankDateSchema.safeParse(field.value).success
? field.value
: undefined,
};
}
export { bankDateField };
+25 -21
View File
@@ -1,7 +1,15 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type BondCandles = NonNullable<paths["/api/v1/bonds/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPosition = NonNullable<paths["/api/v1/bonds/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_BOND_SEARCH_LENGTH = 2; const MIN_BOND_SEARCH_LENGTH = 2;
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondMetrics = NonNullable<paths["/api/v1/bonds/{uid}/metrics"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPeriod = NonNullable< type BondPeriod = NonNullable<
NonNullable< NonNullable<
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
@@ -10,35 +18,28 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000; const BOND_STALE_TIME_MS = 30_000;
function bondQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
params: { path: { uid } },
}),
staleTime: BOND_STALE_TIME_MS,
};
}
function useBond(identifier: string) { function useBond(identifier: string) {
const isUid const isUid
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier, identifier,
); );
const byUid = useResourceQuery({ // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
...bondQueryOptions(identifier), const init = isUid
enabled: Boolean(identifier) && isUid, ? { params: { path: { uid: identifier } } }
}); : { params: { path: { ticker: identifier } } };
const byTicker = useResourceQuery({ const options = apiQueryClient.queryOptions(
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { "get",
params: { path: { ticker: identifier } }, isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}",
}), init,
enabled: Boolean(identifier) && !isUid, );
return useResourceQuery({
...options,
enabled: Boolean(identifier),
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker;
} }
function useBondCandles(uid: string, period: BondPeriod) { function useBondCandles(uid: string, period: BondPeriod) {
@@ -104,7 +105,10 @@ function useBondSearch(query: string) {
} }
export { export {
type BondPeriod, type BondCandles,
type BondCoupons,
type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
+4 -1
View File
@@ -1,5 +1,8 @@
export { export {
type BondPeriod, type BondCandles,
type BondCoupons,
type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
@@ -68,6 +68,7 @@ function useBrokerageOperations(
export { export {
type BrokerageOperation, type BrokerageOperation,
type BrokerageOperationsQuery,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
brokerageOperationsQueryParameters as brokerageOperationsQueryParams, brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
operationCategories, operationCategories,
+1
View File
@@ -5,4 +5,5 @@ export {
type OperationCategory, type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
@@ -1,19 +1,20 @@
import { useQueryClient } from "@tanstack/react-query"; import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
function useInstrumentOperations( import {
uid: string, type BrokerageOperationsQuery,
filter: string, brokerageOperationsQueryOptions,
categories: OperationCategory[], useBrokerageOperations,
} from "../api/useBrokerageOperations";
function useBrokerageOperationHistory(
accountId: string | undefined,
firstPage: Omit<BrokerageOperationsQuery, "cursor">,
) { ) {
const scope = `${uid}:${filter}`; const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage);
const scope = hashKey(queryKey);
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
cursors: string[]; cursors: string[];
@@ -22,13 +23,14 @@ function useInstrumentOperations(
const cursors = pagination.scope === scope ? pagination.cursors : []; const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient(); const client = useQueryClient();
const firstPage = { categories, instrumentUid: uid, limit: 20 };
const operations = useBrokerageOperations(uid ? "all" : undefined, { const operations = useBrokerageOperations(accountId, {
...firstPage, ...firstPage,
cursor: cursors.at(-1), cursor: cursors.at(-1),
}); });
const reset = () => setPagination({ cursors: [], scope });
const retry = (retryScreen: () => unknown) => { const retry = (retryScreen: () => unknown) => {
if ( if (
isApiErrorCode( isApiErrorCode(
@@ -36,10 +38,10 @@ function useInstrumentOperations(
ApiErrorCode.InvalidOperationsCursor, ApiErrorCode.InvalidOperationsCursor,
) )
) { ) {
setPagination({ cursors: [], scope }); reset();
void client.invalidateQueries({ void client.invalidateQueries({
exact: true, exact: true,
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, queryKey,
}); });
} }
else void retryScreen(); else void retryScreen();
@@ -56,8 +58,9 @@ function useInstrumentOperations(
}, },
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }), onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
operations, operations,
reset,
retry, retry,
}; };
} }
export { useInstrumentOperations }; export { useBrokerageOperationHistory };
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
withDetails?: boolean; withDetails?: boolean;
wrap?: boolean;
}; };
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => { const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
tone={payment.tone} tone={payment.tone}
value={payment.amount} value={payment.amount}
valueLabel={t(payment.label)} valueLabel={t(payment.label)}
wrap={props.wrap}
/> />
); );
}; };
@@ -1,85 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
describe("useBrokerageAccounts", () => {
it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
),
),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]);
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data?.[0].id).toBe("account-1");
});
});
@@ -1,6 +1,7 @@
import type { InvestmentResourceMeta } from "@/shared/api";
import type { import type {
BrokerageAccount, BrokerageAccount,
useBrokerageAccounts,
} from "../api/useBrokerageAccounts"; } from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2; const VISIBLE_CURRENCY_LIMIT = 2;
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null positionCounts: null
| { bonds: number; stocks: number }; | { bonds: number; stocks: number };
source?: NonNullable< source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined
? T
: never;
summary: summary:
| { remainingCurrencyCount: number; state: "available"; totals: Money[] } | { remainingCurrencyCount: number; state: "available"; totals: Money[] }
| { state: "empty" } | { state: "empty" }
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards( function brokerageAccountCards(
accounts: BrokerageAccount[], accounts: BrokerageAccount[],
dependencies: NonNullable< dependencies: InvestmentResourceMeta["dependencies"] = [],
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] = [],
): BrokerageAccountCard[] { ): BrokerageAccountCard[] {
return accounts.map((account) => { return accounts.map((account) => {
const source = dependencies.find( const source = dependencies.find(
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
); );
}; };
type BrokerageAccountSourceStatusProps = {
source: BrokerageAccountCard["source"];
};
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props,
) => {
const { source } = props;
const { t } = useTranslation();
const isSyncing
= source?.sync.state === "running" || source?.sync.state === "queued";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
{isSyncing && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props; const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
{!!source?.lastSuccessfulAt && ( <BrokerageAccountSourceStatus source={source} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
)}
{(source?.sync.state === "running"
|| source?.sync.state === "queued") && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
</Stack> </Stack>
{!!positionCounts && ( {!!positionCounts && (
+1
View File
@@ -2,6 +2,7 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
@@ -0,0 +1,10 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid,
event.figi, event.ticker, event.logoName,
]);
}
export { brokerageEventKey };
@@ -34,7 +34,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(event.logoName)} imageSrc={instrumentLogoUrl(event.logoName)}
initial={( initial={(
(event.ticker ?? event.displayName).trim()[0] ?? "?" event.ticker.trim()[0] ?? "?"
).toUpperCase()} ).toUpperCase()}
metadata={metadata} metadata={metadata}
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
@@ -5,10 +5,11 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
@@ -32,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props; const { events } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" && ( {events.state === "loaded" && (
<> <SourceSyncStatus
{!!events.lastSuccessfulAt && ( errorText={errorText}
<Text c="dimmed" size="xs"> lastSuccessfulAt={events.lastSuccessfulAt}
{t("investment.sourceTime", { stale={events.stale}
date: formatSnapshotTime(events.lastSuccessfulAt), />
})}
</Text>
)}
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
)} )}
{events.state === "loading" && ( {events.state === "loading" && (
@@ -66,15 +64,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
&& events.items.map(event => ( && events.items.map(event => (
<BrokerageEventCard <BrokerageEventCard
event={event} event={event}
key={JSON.stringify([ key={brokerageEventKey(event)}
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/> />
))} ))}
+4
View File
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable< type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+1
View File
@@ -1,5 +1,6 @@
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("loads the account portfolio", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({ positions: [] });
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource({
cashBalances: [],
isStale: false,
lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [],
}),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toMatchObject({
lastSuccessfulAt: "2026-08-14T00:00:00Z",
});
});
});
+22 -40
View File
@@ -1,13 +1,17 @@
import { import {
apiQueryClient, apiQueryClient,
type paths, type paths,
unwrapResourceResult,
useAuthenticatedQueries,
useResourceQuery, useResourceQuery,
} from "@/shared/api"; } from "@/shared/api";
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2; const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
NonNullable< NonNullable<
paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"]
@@ -16,35 +20,28 @@ type StockPeriod = NonNullable<
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function stockQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
params: { path: { uid } },
}),
staleTime: STOCK_STALE_TIME_MS,
};
}
function useStock(identifier: string) { function useStock(identifier: string) {
const isUid const isUid
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier, identifier,
); );
const byUid = useResourceQuery({ // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
...stockQueryOptions(identifier), const init = isUid
enabled: Boolean(identifier) && isUid, ? { params: { path: { uid: identifier } } }
}); : { params: { path: { ticker: identifier } } };
const byTicker = useResourceQuery({ const options = apiQueryClient.queryOptions(
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { "get",
params: { path: { ticker: identifier } }, isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}",
}), init,
enabled: Boolean(identifier) && !isUid, );
return useResourceQuery({
...options,
enabled: Boolean(identifier),
staleTime: STOCK_STALE_TIME_MS, staleTime: STOCK_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker;
} }
function useStockCandles(uid: string, period: StockPeriod) { function useStockCandles(uid: string, period: StockPeriod) {
@@ -99,22 +96,6 @@ function useStockQuote(uid: string) {
}); });
} }
function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(
uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)),
);
return new Map(
queries.map((query, index) => [
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
uniqueUids[index],
unwrapResourceResult(query).data,
]),
);
}
function useStockSearch(query: string) { function useStockSearch(query: string) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
@@ -127,13 +108,14 @@ function useStockSearch(query: string) {
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockPeriod, type StockCandles,
type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStocks,
useStockSearch, useStockSearch,
}; };
+3 -2
View File
@@ -1,12 +1,13 @@
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockPeriod, type StockCandles,
type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStocks,
useStockSearch, useStockSearch,
} from "./api/queries"; } from "./api/queries";
+1 -2
View File
@@ -3,12 +3,11 @@ import { createContext, use } from "react";
import type { paths } from "@/shared/api"; import type { paths } from "@/shared/api";
type AuthContextValue = { type AuthContextValue = {
accessToken: string | undefined;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
ready: boolean; ready: boolean;
refresh: () => Promise<boolean>;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string) => Promise<void>;
signedOut: boolean;
user: undefined | User; user: undefined | User;
}; };
+36 -10
View File
@@ -27,6 +27,32 @@ function createAuthWrapper(
); );
} }
async function expireSession() {
server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
{ once: true }));
try {
await apiClient.GET("/api/v1/brokerage-accounts");
}
catch (error) {
// A refresh that changes identity cancels the original user's request.
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
}
}
async function requestAuthorization() {
let authorization: null | string = null;
server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json(resource([]));
}, { once: true }));
await apiClient.GET("/api/v1/brokerage-accounts");
return authorization;
}
describe("AuthProvider", () => { describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", async () => { it("authorizes requests after restoring the initial session", async () => {
let authorization: null | string let authorization: null | string
@@ -77,7 +103,7 @@ describe("AuthProvider", () => {
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
@@ -106,13 +132,13 @@ describe("AuthProvider", () => {
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => await waitFor(() =>
expect(result.current.accessToken).toBe("restored-token"), expect(result.current.ready).toBe(true),
); );
const response = await apiClient.GET("/api/v1/brokerage-accounts"); const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401); expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeUndefined()); await waitFor(() => expect(result.current.user).toBeUndefined());
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
@@ -147,7 +173,7 @@ describe("AuthProvider", () => {
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.accessToken).toBe("test-token")); await waitFor(() => expect(result.current.ready).toBe(true));
expect(queryClient.getQueryData(bankCardsKey)).toEqual([ expect(queryClient.getQueryData(bankCardsKey)).toEqual([
{ id: "bank-card-1" }, { id: "bank-card-1" },
@@ -155,7 +181,7 @@ describe("AuthProvider", () => {
await act(() => result.current.logout()); await act(() => result.current.logout());
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(statusKey)).toBeUndefined();
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
@@ -265,7 +291,7 @@ describe("AuthProvider", () => {
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));
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
await act(() => result.current.refresh()); await act(() => expireSession());
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
let release!: (value: string) => void; let release!: (value: string) => void;
@@ -283,7 +309,7 @@ describe("AuthProvider", () => {
.catch(() => {}); .catch(() => {});
identity = "b"; identity = "b";
await act(() => result.current.refresh()); await act(() => expireSession());
release("a-data"); release("a-data");
await pending; await pending;
expect(result.current.user?.id).toBe("b"); expect(result.current.user?.id).toBe("b");
@@ -319,12 +345,12 @@ describe("AuthProvider", () => {
); );
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("a-card"), expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
); );
identity = "b"; identity = "b";
await act(() => result.current.auth.refresh()); await act(() => expireSession());
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("b-card"), expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
); );
}); });
}); });
+4 -5
View File
@@ -29,9 +29,9 @@ async function request(
function useAuthProviderVm() { function useAuthProviderVm() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [user, setUser] = useState<undefined | User>(undefined); const [user, setUser] = useState<undefined | User>(undefined);
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
const accessTokenRef = useRef<string | undefined>(undefined); const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const generationRef = useRef(0); const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined); const userRef = useRef<undefined | User>(undefined);
@@ -58,6 +58,7 @@ function useAuthProviderVm() {
const changedIdentity const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id; = userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
accessTokenRef.current = authentication.accessToken; accessTokenRef.current = authentication.accessToken;
if (changedIdentity) { if (changedIdentity) {
@@ -69,7 +70,6 @@ function useAuthProviderVm() {
userRef.current = authentication.user; userRef.current = authentication.user;
setUser(authentication.user); setUser(authentication.user);
setAccessToken(authentication.accessToken);
}, },
[queryClient], [queryClient],
); );
@@ -78,7 +78,6 @@ function useAuthProviderVm() {
generationRef.current += 1; generationRef.current += 1;
userRef.current = undefined; userRef.current = undefined;
setUser(undefined); setUser(undefined);
setAccessToken(undefined);
accessTokenRef.current = undefined; accessTokenRef.current = undefined;
queryClient.clear(); queryClient.clear();
}, [queryClient]); }, [queryClient]);
@@ -139,6 +138,7 @@ function useAuthProviderVm() {
} }
async function logout() { async function logout() {
setSignedOut(true);
clearSession(); clearSession();
await serialize(async () => { await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout"); await apiClient.POST("/api/v1/auth/logout");
@@ -156,14 +156,13 @@ function useAuthProviderVm() {
useEffect(() => () => configureAuthClient(), []); useEffect(() => () => configureAuthClient(), []);
const authContextValue = { const authContextValue = {
accessToken,
login: (email: string, password: string) => login: (email: string, password: string) =>
authenticate("/api/v1/auth/login", email, password), authenticate("/api/v1/auth/login", email, password),
logout, logout,
ready, ready,
refresh,
register: (email: string, password: string) => register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password), authenticate("/api/v1/auth/register", email, password),
signedOut,
user, user,
}; };
@@ -1,4 +0,0 @@
.fieldset {
border: 0;
padding: 0;
}
+10 -11
View File
@@ -1,9 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -12,7 +12,6 @@ import {
loginSchema, loginSchema,
registerSchema, registerSchema,
} from "../../model/auth.schema"; } from "../../model/auth.schema";
import classes from "./AuthForm.module.css";
type AuthFormProps = { type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const schema = register ? registerSchema : loginSchema; const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({ const methods = useForm<AuthFormValues>({
resolver: zodResolver(schema) as Resolver<AuthFormValues>, resolver: zodResolver(schema),
}); });
const { const {
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const [submitError, setSubmitError] = useState<null | string>(null); const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit2 = methods.handleSubmit(async (values) => { const submit = methods.handleSubmit(async (values) => {
setSubmitError(null); setSubmitError(null);
try { try {
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit2}> <form onSubmit={submit}>
<fieldset className={classes.fieldset} disabled={isSubmitting}> <Fieldset disabled={isSubmitting} variant="unstyled">
<Stack> <Stack>
<TextInput <TextInput
label={t("auth.email")} label={t("auth.email")}
{...methods.register("email")} {...methods.register("email")}
error={errors.email?.message as string} error={errors.email?.message}
/> />
<PasswordInput <PasswordInput
label={t("auth.password")} label={t("auth.password")}
{...methods.register("password")} {...methods.register("password")}
error={errors.password?.message as string} error={errors.password?.message}
/> />
{register && ( {register && (
<PasswordInput <PasswordInput
label={t("auth.passwordConfirmation")} label={t("auth.passwordConfirmation")}
{...methods.register("passwordConfirmation")} {...methods.register("passwordConfirmation")}
error={errors.passwordConfirmation?.message as string} error={errors.passwordConfirmation?.message}
/> />
)} )}
@@ -82,7 +81,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.haveAccount" : "auth.noAccount")} {t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button> </Button>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
); );
+3 -3
View File
@@ -1,12 +1,12 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm"; import { AuthForm } from "../AuthForm/AuthForm";
import { useLoginFormVm } from "./LoginForm.vm";
const LoginForm: FC = () => { const LoginForm: FC = () => {
const { onSubmit } = useLoginFormVm(); const { login } = useAuth();
return <AuthForm onSubmit={onSubmit} />; return <AuthForm onSubmit={login} />;
}; };
export { LoginForm }; export { LoginForm };
@@ -1,28 +0,0 @@
import type { ComponentProps } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm";
function useLoginFormVm() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo
= (location.state as null
| { returnTo?: string })?.returnTo ?? "/accounts";
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
email,
password,
) => {
await login(email, password);
await navigate(returnTo, { replace: true });
};
return { onSubmit };
}
export { useLoginFormVm };
@@ -1,12 +1,12 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm"; import { AuthForm } from "../AuthForm/AuthForm";
import { useRegisterFormVm } from "./RegisterForm.vm";
const RegisterForm: FC = () => { const RegisterForm: FC = () => {
const { onSubmit } = useRegisterFormVm(); const { register } = useAuth();
return <AuthForm onSubmit={onSubmit} register />; return <AuthForm onSubmit={register} register />;
}; };
export { RegisterForm }; export { RegisterForm };
@@ -1,28 +0,0 @@
import type { ComponentProps } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm";
function useRegisterFormVm() {
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo
= (location.state as null
| { returnTo?: string })?.returnTo ?? "/accounts";
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
email,
password,
) => {
await register(email, password);
await navigate(returnTo, { replace: true });
};
return { onSubmit, register };
}
export { useRegisterFormVm };
+13 -2
View File
@@ -1,6 +1,17 @@
export { section } from "./model/section"; export { toInstrumentEventRows } from "./model/operation-rows";
export { useInstrumentOperations } from "./model/useInstrumentOperations"; export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
export {
InstrumentMetrics,
InstrumentPage,
InstrumentPrice,
InstrumentTerms,
} from "./ui/InstrumentLayout.view";
export { InstrumentMetric } from "./ui/InstrumentMetric/InstrumentMetric.view";
export { InstrumentPageState } from "./ui/InstrumentPageState.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view";
export { InstrumentSection } from "./ui/InstrumentSection.view"; export { InstrumentSection } from "./ui/InstrumentSection.view";
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { toInstrumentEventRows } from "./operation-rows";
const operation: BrokerageOperation = {
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка",
figi: null,
id: "first",
instrumentType: "stock",
instrumentUid: "uid",
logoName: null,
occurredAt: "2026-01-01T00:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: null,
type: "OPERATION_TYPE_BUY",
};
it("fills only missing identity without changing rows or their order", () => {
const items = [operation, {
...operation,
id: "second",
logoName: "",
ticker: "own",
}];
const original = structuredClone(items);
expect(toInstrumentEventRows(items, {
logoName: "fallback.png",
ticker: "fallback",
})).toEqual([
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
items[1],
]);
expect(items).toEqual(original);
expect(toInstrumentEventRows(items)).toEqual(items);
expect(toInstrumentEventRows()).toEqual([]);
});
@@ -0,0 +1,14 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
function toInstrumentEventRows(
items: BrokerageOperation[] = [],
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
): BrokerageOperation[] {
return items.map(item => ({
...item,
logoName: item.logoName ?? instrument?.logoName ?? null,
ticker: item.ticker ?? instrument?.ticker ?? null,
}));
}
export { toInstrumentEventRows };
@@ -1,7 +1,4 @@
import type { paths } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type Meta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type SectionState = { type SectionState = {
error: boolean; error: boolean;
@@ -20,7 +17,7 @@ function section(
data: unknown; data: unknown;
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: Meta; meta?: InvestmentResourceMeta;
refetch: () => unknown; refetch: () => unknown;
}, },
retry?: () => unknown, retry?: () => unknown,
+9 -12
View File
@@ -1,23 +1,22 @@
import type { BondCandles, BondPosition } from "@/entities/bond";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api"; import type { StockCandles, StockPosition } from "@/entities/stock";
import type { SectionState } from "./section"; import type { SectionState } from "./section";
type Candles = NonNullable< type Candles = Pick<BondCandles | StockCandles,
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] "candles"
| "currency"
| "historyFrom"
>; >;
type ChartRow = Candles["candles"][number] & { date: string; price: number }; type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
type InstrumentChartVm = { type InstrumentChartVm = {
averageLine: null | number;
candles?: Candles; candles?: Candles;
candlesState: SectionState; candlesState: SectionState;
chartData: ChartRow[];
hasIncompleteCandle: boolean;
period: string; period: string;
position?: Position; position?: Position;
priceRows: ChartRow[];
setPeriod: (value: string) => void; setPeriod: (value: string) => void;
}; };
@@ -37,8 +36,6 @@ type InstrumentPositionVm = {
positionState: SectionState; positionState: SectionState;
}; };
type Position = NonNullable< type Position = BondPosition | StockPosition;
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm };
@@ -0,0 +1,43 @@
import { useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
const instrumentPeriods = ["month", "halfYear", "year", "all"] as const;
function useInstrumentParameters(incomeFilter: "coupons" | "dividends") {
const [parameters, setParameters] = useSearchParams();
const period = instrumentPeriods.find(
value => value === parameters.get("period"),
) ?? "month";
const defaultFilter: { categories: OperationCategory[]; value: string } = { categories: [], value: "all" };
const filters = [
defaultFilter,
{ categories: ["trade"], value: "trades" },
{ categories: ["income"], value: incomeFilter },
{ categories: ["tax", "fee", "transfer", "other"], value: "other" },
] satisfies { categories: OperationCategory[]; value: string }[];
const { categories, value: filter } = filters.find(
item => item.value === parameters.get("events"),
) ?? defaultFilter;
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParameters(next, { replace: true });
};
return {
categories,
filter,
period,
search: parameters.size > 0 ? `?${parameters.toString()}` : "",
setFilter: (value: string) => setParameter("events", value),
setPeriod: (value: string) => setParameter("period", value),
};
}
export { instrumentPeriods, useInstrumentParameters };
@@ -10,20 +10,19 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { formatMoney } from "@/shared/lib"; import { formatChartValue, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
import classes from "../InstrumentDetails.module.css"; import classes from "../InstrumentDetails.module.css";
import { InstrumentSection } from "../InstrumentSection.view"; import { InstrumentSection } from "../InstrumentSection.view";
import { useInstrumentChartVm } from "./InstrumentChart.vm"; import { useInstrumentChartVm } from "./InstrumentChart.vm";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
const InstrumentChart: FC<InstrumentChartProps> = (props) => { const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const { const {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle,
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,
@@ -34,14 +33,14 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
let chartContent; let chartContent;
if (vm.candles) { if (vm.candles) {
if (vm.chartData.length > 0) { if (chartData.length > 0) {
chartContent = ( chartContent = (
<> <>
<AreaChart <AreaChart
areaProps={{ isAnimationActive: false }} areaProps={{ isAnimationActive: false }}
aria-label={chartLabel} aria-label={chartLabel}
curveType="linear" curveType="linear"
data={vm.chartData} data={chartData}
dataKey="date" dataKey="date"
gridAxis="y" gridAxis="y"
h={310} h={310}
@@ -52,7 +51,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
tickLine="none" tickLine="none"
tooltipAnimationDuration={0} tooltipAnimationDuration={0}
valueFormatter={formatChartValue} valueFormatter={formatChartValue}
withDots={vm.chartData.length === 1} withDots={chartData.length === 1}
withGradient withGradient
yAxisProps={{ domain: ["auto", "auto"], width: 64 }} yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
/> />
@@ -65,13 +64,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
)} )}
</Text> </Text>
{!!vm.hasIncompleteCandle && ( {!!hasIncompleteCandle && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.incomplete")} {t("stock.incomplete")}
</Text> </Text>
)} )}
{vm.averageLine !== null && ( {referenceLines.length > 0 && (
<Text mt="xs" size="sm"> <Text mt="xs" size="sm">
{t("stock.averageLine")}:{" "} {t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice)} {formatMoney(vm.position?.averagePurchasePrice)}
@@ -88,7 +87,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
<Text fw={600}>{t("stock.priceTable")}</Text> <Text fw={600}>{t("stock.priceTable")}</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.priceTableHint", { count: vm.chartData.length })} {t("stock.priceTableHint", { count: chartData.length })}
</Text> </Text>
</Accordion.Control> </Accordion.Control>
@@ -1,16 +1,29 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib"; import { formatDate, formatDecimal, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; import { instrumentPeriods } from "../../model/useInstrumentParameters";
function useInstrumentChartVm(props: InstrumentChartProps) { function useInstrumentChartVm(props: InstrumentChartProps) {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const priceRows = vm.priceRows.map((candle) => { const chartData = vm.candles?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value)
: null;
const priceRows = chartData.toReversed().map((candle) => {
const currency = vm.candles?.currency; const currency = vm.candles?.currency;
const value = candle.close; const value = candle.close;
@@ -23,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
return { ...candle, price }; return { ...candle, price };
}); });
const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ const periodOptions = instrumentPeriods.map(value => ({
label: t(`stock.periods.${value}`), label: t(`stock.periods.${value}`),
value, value,
})); }));
@@ -39,20 +52,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
); );
const referenceLines const referenceLines
= vm.averageLine === null = averageLine === null || !Number.isFinite(averageLine)
? [] ? []
: [ : [
{ {
color: "orange.7", color: "orange.7",
ifOverflow: "extendDomain" as const, ifOverflow: "extendDomain" as const,
label: t("stock.averageLine"), label: t("stock.averageLine"),
y: vm.averageLine, y: averageLine,
}, },
]; ];
return { return {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle: chartData.some(candle => !candle.isComplete),
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,
@@ -60,3 +60,31 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.metricsGrid {
--instrument-metric-padding: var(--mantine-spacing-sm);
--instrument-metric-background: var(--wl-color-subtle);
--instrument-metric-radius: var(--mantine-radius-md);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
@media (max-width: 48em) {
.metricsGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -0,0 +1,17 @@
.header {
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
min-width: 0;
overflow-wrap: anywhere;
}
.identity {
display: flex;
align-items: center;
gap: var(--mantine-spacing-md);
min-width: 0;
}
@media (max-width: 48em) {
.header {
padding: var(--mantine-spacing-md);
}
}
@@ -0,0 +1,45 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "./InstrumentHeader.module.css";
type InstrumentHeaderProps = PropsWithChildren<{
logoSrc: null | string;
name: string;
onRefresh: () => void;
refreshing: boolean;
subtitle: ReactNode;
}>;
const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props;
const { t } = useTranslation();
return (
<Paper className={classes.header} component="header" withBorder>
<Group align="start" justify="space-between">
<div className={classes.identity}>
<Avatar alt="" key={logoSrc} radius="md" size={56} src={logoSrc}>
{name[0]}
</Avatar>
<div>
<Title order={1} size="h2">{name}</Title>
<Text c="dimmed" size="sm">{subtitle}</Text>
</div>
</div>
<Button disabled={refreshing} onClick={onRefresh} variant="subtle">
{t("investment.refresh")}
</Button>
</Group>
{children}
</Paper>
);
};
export { InstrumentHeader };
@@ -0,0 +1,31 @@
import type { FC, PropsWithChildren } from "react";
import { Stack, Text, type TextProps, Title } from "@mantine/core";
import classes from "./InstrumentDetails.module.css";
type InstrumentPriceProps = PropsWithChildren<Pick<TextProps, "mt">>;
type InstrumentTermsProps = PropsWithChildren<{ title: string }>;
const InstrumentPage: FC<PropsWithChildren> = props => (
<Stack className={classes.page} gap="lg">{props.children}</Stack>
);
const InstrumentPrice: FC<InstrumentPriceProps> = props => (
<Text className={classes.price} mt={props.mt}>{props.children}</Text>
);
const InstrumentMetrics: FC<PropsWithChildren> = props => (
<div className={classes.metricsGrid}>{props.children}</div>
);
const InstrumentTerms: FC<InstrumentTermsProps> = props => (
<div className={classes.embeddedSection}>
<Title mb="md" order={2} size="h3">{props.title}</Title>
{props.children}
</div>
);
export { InstrumentMetrics, InstrumentPage, InstrumentPrice, InstrumentTerms };
@@ -0,0 +1,5 @@
.metric {
padding: var(--instrument-metric-padding, 0);
background: var(--instrument-metric-background, transparent);
border-radius: var(--instrument-metric-radius, 0);
}
@@ -0,0 +1,21 @@
import type { FC } from "react";
import { Text } from "@mantine/core";
import classes from "./InstrumentMetric.module.css";
type InstrumentMetricProps = { label: string; value: string };
const InstrumentMetric: FC<InstrumentMetricProps> = props => (
<div className={classes.metric}>
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
{props.value}
</Text>
</div>
);
export { InstrumentMetric };
@@ -0,0 +1,66 @@
import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type InstrumentPageStateProps = {
code?: string;
kind: "bond" | "stock";
loading: boolean;
retry: () => void;
};
const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
const { code, kind, loading, retry } = props;
const { t } = useTranslation();
if (loading) return (
<LoadingSkeleton label={t(`${kind}.loading`)}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
const ambiguousCode = kind === "stock"
? ApiErrorCode.StockTickerAmbiguous
: ApiErrorCode.BondTickerAmbiguous;
let message = t(`${kind}.unavailableText`);
if (isApiErrorCode(code, ambiguousCode))
message = t(`${kind}.ambiguousTicker`);
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
)
message = t(`${kind}.connect`);
return (
<Stack role="alert">
<Title order={1}>{t(`${kind}.unavailable`)}</Title>
<Text>{message}</Text>
<Anchor component={Link} to={`/${kind}s`}>
{t(`${kind}.backToSearch`)}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
};
export { InstrumentPageState };
@@ -0,0 +1,33 @@
import type { FC } from "react";
import { Badge, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
type InstrumentQuoteStatusProps = {
quote?: { priceAt: null | string; tradingStatus: null | string };
};
const InstrumentQuoteStatus: FC<InstrumentQuoteStatusProps> = (props) => {
const { quote } = props;
const { t } = useTranslation();
return (
<>
<Badge color="gray" variant="light">
{t(`stock.trading.${quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", { date: formatSnapshotTime(quote.priceAt) })}
</Text>
)}
</>
);
};
export { InstrumentQuoteStatus };
@@ -13,7 +13,7 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls} {props.controls}
</Group> </Group>
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>} <SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>} {!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.state.lastSuccessfulAt),
})}
</Text>
)}
{!!props.state.refreshing && ( {!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs"> <Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")} {t("accounts.refreshing")}
+3
View File
@@ -0,0 +1,3 @@
export { useInstrumentSearch } from "./model/useInstrumentSearch";
export { InstrumentSearch } from "./ui/InstrumentSearch/InstrumentSearch.view";
export { InstrumentSearchCard } from "./ui/InstrumentSearchCard/InstrumentSearchCard.view";
@@ -0,0 +1,25 @@
import type { ChangeEvent } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
const SEARCH_DEBOUNCE_MS = 300;
function useInstrumentSearch(minLength: number) {
const [parameters, setParameters] = useSearchParams();
const query = parameters.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
return {
canSearch: query.trim().length >= minLength,
debounced,
isWaiting: query.trim() !== debounced,
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
const q = event.currentTarget.value;
setParameters(q ? { q } : {}, { replace: true });
},
query,
};
}
export { useInstrumentSearch };
@@ -0,0 +1,61 @@
import type { ComponentProps } from "react";
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { expect, it, vi } from "vitest";
import { InstrumentSearch } from "./InstrumentSearch.view";
it("keeps cached results on failure, hides them during a new search and distinguishes partial from empty", () => {
const vm = {
canSearch: true,
error: false,
loading: false,
onQueryChange: vi.fn(),
partial: false,
query: "SBER",
ready: true,
refreshing: false,
retry: vi.fn(),
};
const cached = [<a href="/stocks/SBER" key="SBER">Сбербанк</a>];
const view = (
state: Partial<ComponentProps<typeof InstrumentSearch>["vm"]>,
children = cached,
) => (
<InstrumentSearch kind="stock" vm={{ ...vm, ...state }}>
{children}
</InstrumentSearch>
);
const { rerender } = render(view({}), {
wrapper: ({ children }) => (
<MantineProvider>
<MemoryRouter>{children}</MemoryRouter>
</MantineProvider>
),
});
rerender(view({ code: "INVALID_TBANK_TOKEN", error: true }));
expect(screen.getByRole("link", { name: "Сбербанк" })).toBeVisible();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(vm.retry).toHaveBeenCalledOnce();
rerender(view({ loading: true, ready: false }));
expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Ищем акции")).toBeVisible();
rerender(view({ partial: true }, []));
expect(screen.queryByText(/Акции не найдены/)).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeVisible();
rerender(view({}, []));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.getByText(/Акции не найдены/)).toBeVisible();
});

Some files were not shown because too many files have changed in this diff Show More