refactor: simplify frontend and clarify module ownership #45
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
|
||||
|
||||
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
|
||||
[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/сценарии целиком, если задача затрагивает один исход.
|
||||
|
||||
## Команды
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||
|
||||
Тип значений формы выводится из её schema, когда input/output совпадают.
|
||||
Общие props определяются у одного owner; DTO и presentation model сохраняют
|
||||
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
|
||||
|
||||
## Читаемость: ESLint и review
|
||||
|
||||
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
|
||||
|
||||
Review проверяет то, чего синтаксис не доказывает:
|
||||
|
||||
Обоснованное config exception сопровождается причиной и явным ожиданием в
|
||||
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
|
||||
настраивать профиль без случайного ослабления остальных правил.
|
||||
|
||||
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||
- Имена derived values описывают значение, например `projectedAmount`.
|
||||
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||
|
||||
+11
-5
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
|
||||
в каталоге: её граф зависимостей не достигает application data/API.
|
||||
`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,
|
||||
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||
@@ -76,9 +82,9 @@ JSX props получают ссылки на функции, включая `onC
|
||||
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
||||
|
||||
VM собирает связные предметные hooks, когда внутри смешиваются независимые
|
||||
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями
|
||||
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook
|
||||
возвращает данные и действия своей ответственности, VM объединяет их для view.
|
||||
сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
|
||||
её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
|
||||
простое подключение одного controller остаётся в VM формы.
|
||||
Не группируй несвязанные функции только по префиксу `on*` и не создавай
|
||||
passthrough hook над одним вызовом ради формального дробления.
|
||||
|
||||
@@ -110,8 +116,8 @@ Render-helper замени самостоятельным presentation component
|
||||
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
||||
|
||||
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
||||
лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
|
||||
остаётся в `bank-card/ui`.
|
||||
лежит рядом со своим view, тестом и story. Формы используют Mantine
|
||||
`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
|
||||
|
||||
## Формы
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
module.exports = {
|
||||
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: {},
|
||||
name: "no-unresolved",
|
||||
|
||||
@@ -153,11 +153,13 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
},
|
||||
},
|
||||
@@ -183,6 +185,44 @@ export default defineConfig(
|
||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||
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}"],
|
||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||
@@ -197,6 +237,7 @@ export default defineConfig(
|
||||
ignoreTypeIndexes: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+21
-21
@@ -4082,9 +4082,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6942,9 +6942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6969,9 +6969,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9668,9 +9668,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10702,9 +10702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14291,9 +14291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/release-it/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14673,9 +14673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz",
|
||||
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
|
||||
"integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
+7
-1
@@ -23,7 +23,7 @@
|
||||
"openapi:check": "node scripts/check-openapi.mjs",
|
||||
"knip": "npm run build:packages && knip",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.1",
|
||||
"undici": "7.29.1"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
"fsevents@2.3.3": true
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
.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>;
|
||||
|
||||
export const LongContentAndFallback: Story = {};
|
||||
|
||||
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
|
||||
|
||||
@@ -18,6 +18,7 @@ type EntryCardProps = {
|
||||
| "positive";
|
||||
value: string;
|
||||
valueLabel: string;
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
@@ -27,11 +28,18 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const titleLineClamp = props.wrap ? undefined : 1;
|
||||
|
||||
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
||||
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||
|
||||
return (
|
||||
<Paper className={classes.row} style={surfaceStyle} withBorder>
|
||||
<Paper
|
||||
className={classes.row}
|
||||
data-wrap={props.wrap || undefined}
|
||||
style={surfaceStyle}
|
||||
withBorder
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@@ -43,7 +51,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
</Avatar>
|
||||
|
||||
<Stack className={classes.identity} gap={2}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
<Text fw={700} lineClamp={titleLineClamp}>
|
||||
{props.title}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
|
||||
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
const eslint = new ESLint();
|
||||
|
||||
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",
|
||||
"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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { useCurrentRoute } from "./useCurrentRoute";
|
||||
@@ -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 () => {
|
||||
renderPage("/accounts/all");
|
||||
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
|
||||
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||
"/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 () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
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 { AuthProvider } from "@/features/auth";
|
||||
@@ -12,10 +14,11 @@ import { server } from "@/test/server";
|
||||
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
function renderRoutes(path: string) {
|
||||
function renderRoutes(path: InitialEntry, controls?: ReactNode) {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
{controls}
|
||||
<MantineProvider
|
||||
cssVariablesResolver={cssVariablesResolver}
|
||||
theme={theme}
|
||||
@@ -57,10 +60,20 @@ describe("AppRoutes", () => {
|
||||
).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;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/login", async ({ request }) => {
|
||||
http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
|
||||
credentials = await request.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"), {
|
||||
target: { value: " User@Example.com " },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
||||
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(
|
||||
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 () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
||||
|
||||
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
|
||||
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
|
||||
element={(
|
||||
<LazyPage variant="content">
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { FC } from "react";
|
||||
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMatch } from "react-router";
|
||||
|
||||
import { useCurrentRoute } from "@/app/model";
|
||||
import { useBankCard } from "@/entities/bank-card";
|
||||
import { useBond } from "@/entities/bond";
|
||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||
@@ -82,14 +82,18 @@ const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
||||
|
||||
const ProtectedDocumentTitle: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const account = useMatch("/accounts/:accountId/:section?");
|
||||
const bond = useMatch("/bonds/:bondId");
|
||||
const stock = useMatch("/stocks/:stockId");
|
||||
const card = useMatch("/cards/:cardId");
|
||||
const cards = useMatch("/cards");
|
||||
const bonds = useMatch("/bonds");
|
||||
const stocks = useMatch("/stocks");
|
||||
const settings = useMatch("/settings");
|
||||
|
||||
const {
|
||||
account,
|
||||
bond,
|
||||
bonds,
|
||||
card,
|
||||
cards,
|
||||
settings,
|
||||
stock,
|
||||
stocks,
|
||||
} = useCurrentRoute();
|
||||
|
||||
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
|
||||
if (account)
|
||||
return (
|
||||
|
||||
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
|
||||
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) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return this.state.hasError
|
||||
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||
: (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
import { Container, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||
if (variant === "auth")
|
||||
return (
|
||||
<Container py="xl" size={420}>
|
||||
<Stack
|
||||
aria-busy="true"
|
||||
aria-label={t("states.loadingTitle")}
|
||||
role="status"
|
||||
>
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="45%" />
|
||||
|
||||
<Skeleton height={48} radius="sm" />
|
||||
@@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||
<Skeleton height={48} radius="sm" />
|
||||
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="30%" />
|
||||
|
||||
<Skeleton height={160} radius="md" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useAuth } from "@/features/auth";
|
||||
@@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback";
|
||||
|
||||
const Protected: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { ready, user } = useAuth();
|
||||
const { ready, signedOut, user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
||||
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
|
||||
const loginState = signedOut && wasAuthenticated
|
||||
? undefined
|
||||
: { returnTo: `${location.pathname}${location.search}` };
|
||||
|
||||
return user
|
||||
? <>{children}</>
|
||||
: (
|
||||
<Navigate
|
||||
replace
|
||||
state={{ returnTo: `${location.pathname}${location.search}` }}
|
||||
state={loginState}
|
||||
to="/login"
|
||||
/>
|
||||
);
|
||||
@@ -30,8 +35,11 @@ const Public: FC<PropsWithChildren> = (props) => {
|
||||
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
|
||||
const requested = (location.state as null
|
||||
| { returnTo?: string })?.returnTo;
|
||||
const state: unknown = location.state;
|
||||
|
||||
const requested = state && typeof state === "object" && "returnTo" in state
|
||||
? state.returnTo
|
||||
: undefined;
|
||||
|
||||
const returnTo
|
||||
= typeof requested === "string"
|
||||
|
||||
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the account hierarchy from operations", () => {
|
||||
renderBreadcrumbs("/accounts/account-1/operations", [
|
||||
it.each([
|
||||
["operations", "Операции"],
|
||||
["OPERATIONS", "Операции"],
|
||||
["events", "Будущие события"],
|
||||
["income", "Пассивный доход"],
|
||||
])("links to the account hierarchy from %s", (section, title) => {
|
||||
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
||||
{ id: "account-1", name: "ИИС" },
|
||||
]);
|
||||
|
||||
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
|
||||
"href",
|
||||
"/accounts/account-1",
|
||||
);
|
||||
expect(screen.getByText("Операции")).toHaveAttribute(
|
||||
expect(screen.getByText(title)).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
|
||||
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);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { FC } from "react";
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMatch } from "react-router";
|
||||
|
||||
import { useCurrentRoute } from "@/app/model";
|
||||
|
||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||
@@ -14,74 +15,34 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
||||
const AppBreadcrumbs: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operations = useMatch({
|
||||
end: true,
|
||||
path: "/accounts/:accountId/operations",
|
||||
});
|
||||
const {
|
||||
account,
|
||||
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 (stock)
|
||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
||||
if (operations)
|
||||
if (account && (section || !account.params.section))
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
<AccountBreadcrumbs
|
||||
accountId={operations.params.accountId ?? ""}
|
||||
section="operations"
|
||||
accountId={account.params.accountId ?? ""}
|
||||
section={section}
|
||||
/>
|
||||
|
||||
<AccountScopeSwitch
|
||||
accountId={operations.params.accountId ?? ""}
|
||||
section="operations"
|
||||
accountId={account.params.accountId ?? ""}
|
||||
section={section}
|
||||
/>
|
||||
</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 (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||
if (accounts)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import type { BankCard, CreateBankOperation } from "./types";
|
||||
|
||||
import {
|
||||
useCreateBankOperation,
|
||||
useDeleteBankOperation,
|
||||
@@ -14,8 +16,6 @@ import {
|
||||
useUpdateExpenseCategory,
|
||||
} from "./mutations";
|
||||
import {
|
||||
type BankCard,
|
||||
type CreateBankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
@@ -106,14 +106,14 @@ function useReadModels() {
|
||||
const categories = useBankCategories();
|
||||
|
||||
const history = useBankOperations("a", {
|
||||
limit: 20,
|
||||
month: "2026-09",
|
||||
page: 2,
|
||||
from: "2026-09-01",
|
||||
limit: 20, page: 2,
|
||||
to: "2026-09-30",
|
||||
});
|
||||
|
||||
const expenses = useBankExpenses("a", { page: 1 });
|
||||
const september = useBankSummary("2026-09");
|
||||
const august = useBankSummary("2026-08", "b");
|
||||
const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
|
||||
const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
|
||||
const original = useBankOperation("original");
|
||||
|
||||
return {
|
||||
@@ -144,7 +144,8 @@ describe("banking API", () => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
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)
|
||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
||||
|
||||
@@ -158,7 +159,7 @@ describe("banking API", () => {
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
@@ -171,7 +172,7 @@ describe("banking API", () => {
|
||||
isFail = true;
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
@@ -179,7 +180,7 @@ describe("banking API", () => {
|
||||
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>();
|
||||
server.use(...readHandlers(counts));
|
||||
|
||||
@@ -191,12 +192,15 @@ describe("banking API", () => {
|
||||
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/a/operations?limit=20&month=2026-09&page=2",
|
||||
);
|
||||
expect(counts.keys()).toContain(
|
||||
"/api/v1/bank-cards/summary?cardId=b&month=2026-08",
|
||||
);
|
||||
const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
|
||||
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(Object.fromEntries(history?.searchParams ?? [])).toEqual({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -303,7 +307,7 @@ describe("banking API", () => {
|
||||
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>();
|
||||
server.use(
|
||||
...readHandlers(counts),
|
||||
@@ -403,7 +407,7 @@ describe("banking API", () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||
expect(result.current.categories.data?.[0]?.name).toBe("Food"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.updateCategory.mutateAsync({
|
||||
@@ -412,7 +416,7 @@ describe("banking API", () => {
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
|
||||
expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.updateOperation.mutateAsync({
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
UpdateBankCard,
|
||||
UpdateBankOperation,
|
||||
UpdateExpenseCategory,
|
||||
} from "./queries";
|
||||
} from "./types";
|
||||
|
||||
async function mutationData<T>(
|
||||
request: Promise<{ data?: T; error?: unknown }>,
|
||||
@@ -46,16 +46,16 @@ function useBankMutationOptions() {
|
||||
return {
|
||||
onError: (error: unknown) => {
|
||||
const errorCode = apiErrorCode(error);
|
||||
if (
|
||||
[
|
||||
ApiErrorCode.BankDependentRefunds,
|
||||
ApiErrorCode.BankIntentConflict,
|
||||
ApiErrorCode.BankRefundLimit,
|
||||
ApiErrorCode.BankVersionConflict,
|
||||
ApiErrorCode.BankWriteConflict,
|
||||
].some(code => isApiErrorCode(errorCode, code))
|
||||
)
|
||||
return invalidate();
|
||||
|
||||
return [
|
||||
ApiErrorCode.BankDependentRefunds,
|
||||
ApiErrorCode.BankIntentConflict,
|
||||
ApiErrorCode.BankRefundLimit,
|
||||
ApiErrorCode.BankVersionConflict,
|
||||
ApiErrorCode.BankWriteConflict,
|
||||
].some(code => isApiErrorCode(errorCode, code))
|
||||
? invalidate()
|
||||
: undefined;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
retry: false,
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
import { moneyCents } from "../model/form-validation";
|
||||
import type { BankOperation, BankOperations, BankSummary } from "./types";
|
||||
|
||||
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"];
|
||||
import { moneyCents } from "../model/money";
|
||||
|
||||
const OPERATIONS_PAGE_SIZE = 100;
|
||||
|
||||
@@ -110,7 +78,6 @@ function useBankOperations(
|
||||
query: {
|
||||
from?: string;
|
||||
limit?: "all" | number;
|
||||
month?: string;
|
||||
page: number;
|
||||
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.
|
||||
return useAuthenticatedQuery({
|
||||
enabled: Boolean(id && (query.month || (query.from && query.to))),
|
||||
enabled: Boolean(id && query.from && query.to),
|
||||
placeholderData: (
|
||||
previousData: BankOperations | undefined,
|
||||
previousQuery,
|
||||
@@ -143,8 +110,7 @@ function useBankOperations(
|
||||
|
||||
return !isAll
|
||||
&& previousQuery?.queryKey.length === options.queryKey.length
|
||||
&& previous?.path?.id === id
|
||||
&& previous?.query?.month === query.month
|
||||
&& previous?.path.id === id
|
||||
&& previous?.query?.from === query.from
|
||||
&& previous?.query?.to === query.to
|
||||
&& 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.
|
||||
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 (!isAll) return data;
|
||||
items.push(...data.items);
|
||||
@@ -178,36 +145,19 @@ function useBankOperations(
|
||||
}
|
||||
|
||||
function useBankSummary(
|
||||
period: string
|
||||
| { from: string; to: string },
|
||||
period: { from: string; to: string },
|
||||
cardId?: string,
|
||||
) {
|
||||
const query
|
||||
= typeof period === "string"
|
||||
? { cardId, month: period }
|
||||
: { ...period, cardId };
|
||||
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
|
||||
params: { query },
|
||||
params: { query: { ...period, cardId } },
|
||||
}),
|
||||
enabled: Boolean(period),
|
||||
select: sortBankSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
type BankSummary,
|
||||
type CreateBankCard,
|
||||
type CreateBankOperation,
|
||||
type CreateExpenseCategory,
|
||||
type ExpenseCategory,
|
||||
sortBankSummary,
|
||||
type UpdateBankCard,
|
||||
type UpdateBankOperation,
|
||||
type UpdateExpenseCategory,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -5,8 +5,6 @@ export {
|
||||
useUpdateBankOperation,
|
||||
} from "./api/mutations";
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
@@ -15,14 +13,16 @@ export {
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
} 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 { bankMoneyValue, moneyCents } from "./model/money";
|
||||
export { bankOperationAppearance } from "./model/operation-appearance";
|
||||
export { bankOperationKinds } from "./model/operation-kinds";
|
||||
export { bankPeriod } from "./model/period";
|
||||
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
||||
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
||||
export { bankDateField } from "./ui/bankDateField";
|
||||
export { BankFeedback } from "./ui/BankFeedback";
|
||||
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
|
||||
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
||||
|
||||
@@ -2,9 +2,11 @@ import { z } from "zod";
|
||||
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
import type { ExpenseCategory } from "../api/queries";
|
||||
import type { ExpenseCategory } from "../api/types";
|
||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||
|
||||
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
|
||||
|
||||
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||
return z.object({
|
||||
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 { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
|
||||
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
|
||||
import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
|
||||
import {
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
positiveMoneySchema,
|
||||
} from "./form.schema";
|
||||
import { bankMoneyValue } from "./money";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
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-02").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;
|
||||
const CENTS_PRECISION = 2;
|
||||
import { moscowDate } from "@/shared/lib";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function bankMoneyValue(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(",", ".")
|
||||
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||
}
|
||||
|
||||
function bankMonth(value: null | string) {
|
||||
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
||||
? value
|
||||
@@ -21,27 +13,7 @@ function bankMonth(value: null | string) {
|
||||
}
|
||||
|
||||
function bankToday() {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
timeZone: "Europe/Moscow",
|
||||
year: "numeric",
|
||||
}).format(new Date());
|
||||
return moscowDate(new Date());
|
||||
}
|
||||
|
||||
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 { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
|
||||
export { bankDateInputValue, bankMonth, bankToday };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { calendarDate } from "@/shared/lib";
|
||||
|
||||
import { bankToday, moneyCents } from "./form-validation";
|
||||
const ISO_DATE_LENGTH = 10;
|
||||
import { bankToday } from "./form-validation";
|
||||
import { moneyCents } from "./money";
|
||||
const MAX_CARD_NAME_LENGTH = 100;
|
||||
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
||||
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||
@@ -34,11 +35,8 @@ const bankDateSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
value =>
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||
calendarDate.safeParse(value).success
|
||||
&& 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(),
|
||||
{ error: validationMessage("bank.validation.date") },
|
||||
);
|
||||
|
||||
@@ -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 { BankOperation } from "../api/queries";
|
||||
import type { BankOperation } from "../api/types";
|
||||
|
||||
const categoryIcons: {
|
||||
appearance: OperationIconProps;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BankOperation } from "../api/queries";
|
||||
import type { BankOperation } from "../api/types";
|
||||
|
||||
const bankOperationKinds = [
|
||||
"expense",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
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 type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
import { useOpeningDateField } from "./useOpeningDateField";
|
||||
import { usePaymentSystemField } from "./usePaymentSystemField";
|
||||
import { bankDateField } from "../bankDateField";
|
||||
|
||||
function useBankCardFieldsVm() {
|
||||
const {
|
||||
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
|
||||
} = useFormContext<BankCardValues>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const openingDate = useOpeningDateField(control);
|
||||
const paymentSystem = usePaymentSystemField(control);
|
||||
const { field: openingDateField } = useController({ control, name: "openingDate" });
|
||||
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 };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import type { BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
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 { FormProvider } from "react-hook-form";
|
||||
|
||||
import { formatDate } from "@/shared/lib";
|
||||
|
||||
import { type BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import classes from "../BankForms.module.css";
|
||||
import { BankCardFields } from "./BankCardForm.view";
|
||||
import { useBankCardFormVm } from "./BankCardForm.vm";
|
||||
|
||||
@@ -40,7 +40,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||
<Fieldset disabled={isPending} variant="unstyled">
|
||||
<Stack>
|
||||
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
||||
|
||||
@@ -80,7 +80,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</Fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
|
||||
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
|
||||
|
||||
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
const {
|
||||
dateParser,
|
||||
errors,
|
||||
onOpeningDateBlur,
|
||||
onOpeningDateChange,
|
||||
onOpeningDateInput,
|
||||
onPaymentSystemChange,
|
||||
openingDateField,
|
||||
openingDateValue,
|
||||
openingDate,
|
||||
paymentSystemField,
|
||||
paymentSystems,
|
||||
register,
|
||||
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
{...openingDateField}
|
||||
dateParser={dateParser}
|
||||
{...openingDate}
|
||||
error={errors.openingDate?.message}
|
||||
fixOnBlur={false}
|
||||
label={t("bank.openingDate")}
|
||||
maxDate={bankToday()}
|
||||
onBlur={onOpeningDateBlur}
|
||||
onChange={onOpeningDateChange}
|
||||
onInput={onOpeningDateInput}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
withinPortal: false,
|
||||
}}
|
||||
readOnly={props.archived}
|
||||
required
|
||||
value={openingDateValue}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
|
||||
import { type BankCard, useBankCard } from "../../api/queries";
|
||||
import { bankMoneyValue, bankToday } from "../../model/form-validation";
|
||||
import { useBankCard } from "../../api/queries";
|
||||
import { bankToday } from "../../model/form-validation";
|
||||
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
||||
import { bankMoneyValue } from "../../model/money";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return { onChange, onPeriodChange, t, value };
|
||||
return { onPeriodChange, t, value };
|
||||
}
|
||||
|
||||
export { useBankPeriodPickerVm };
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { BankSummary } from "../../api/queries";
|
||||
import type { BankSummary } from "../../api/types";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import classes from "./BankSummary.module.css";
|
||||
@@ -78,6 +78,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
|
||||
name: "Переименовать",
|
||||
});
|
||||
|
||||
assert.isDefined(renameButtons[0]);
|
||||
fireEvent.click(renameButtons[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||
);
|
||||
await waitFor(() => expect(reads).toBe(3));
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Переименовать" })[1],
|
||||
);
|
||||
const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
|
||||
assert.isDefined(secondRename);
|
||||
fireEvent.click(secondRename);
|
||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: "Новая вторая" },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
Badge,
|
||||
import { Badge,
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
@@ -12,17 +12,14 @@ import { type FC } from "react";
|
||||
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CategoryValues } from "../../model/category.schema";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import classes from "../BankForms.module.css";
|
||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
import {
|
||||
type CategoryManagerProps,
|
||||
useCategoryManagerVm,
|
||||
} from "./CategoryManager.vm";
|
||||
|
||||
const CategoryFields: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -76,7 +73,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||
<Fieldset disabled={isPending} variant="unstyled">
|
||||
<Stack>
|
||||
{categoryRows?.map((category) => {
|
||||
return (
|
||||
@@ -149,7 +146,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</Fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ExpenseCategory } from "../../api/types";
|
||||
|
||||
import {
|
||||
useCreateExpenseCategory,
|
||||
useUpdateExpenseCategory,
|
||||
} from "../../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||
import { categorySchema } from "../../model/category.schema";
|
||||
import { useBankCategories } from "../../api/queries";
|
||||
import { categorySchema, type CategoryValues } from "../../model/category.schema";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
@@ -18,8 +20,6 @@ type CategoryManagerProps = {
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
|
||||
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
@@ -41,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
const onRequestClose = () => !isPending && setOpened(false);
|
||||
|
||||
const finishEditing = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
};
|
||||
|
||||
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||
void methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
@@ -59,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
finishEditing();
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
@@ -88,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
finishEditing();
|
||||
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 };
|
||||
@@ -1,7 +1,15 @@
|
||||
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;
|
||||
|
||||
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<
|
||||
NonNullable<
|
||||
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
|
||||
@@ -10,35 +18,28 @@ type BondPeriod = NonNullable<
|
||||
|
||||
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) {
|
||||
const isUid
|
||||
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
identifier,
|
||||
);
|
||||
|
||||
const byUid = useResourceQuery({
|
||||
...bondQueryOptions(identifier),
|
||||
enabled: Boolean(identifier) && isUid,
|
||||
});
|
||||
// eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
|
||||
const init = isUid
|
||||
? { params: { path: { uid: identifier } } }
|
||||
: { params: { path: { ticker: identifier } } };
|
||||
|
||||
const byTicker = useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
||||
params: { path: { ticker: identifier } },
|
||||
}),
|
||||
enabled: Boolean(identifier) && !isUid,
|
||||
const options = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}",
|
||||
init,
|
||||
);
|
||||
|
||||
return useResourceQuery({
|
||||
...options,
|
||||
enabled: Boolean(identifier),
|
||||
staleTime: BOND_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
return isUid ? byUid : byTicker;
|
||||
}
|
||||
|
||||
function useBondCandles(uid: string, period: BondPeriod) {
|
||||
@@ -104,7 +105,10 @@ function useBondSearch(query: string) {
|
||||
}
|
||||
|
||||
export {
|
||||
type BondPeriod,
|
||||
type BondCandles,
|
||||
type BondCoupons,
|
||||
type BondMetrics,
|
||||
type BondPosition,
|
||||
MIN_BOND_SEARCH_LENGTH,
|
||||
useBond,
|
||||
useBondCandles,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export {
|
||||
type BondPeriod,
|
||||
type BondCandles,
|
||||
type BondCoupons,
|
||||
type BondMetrics,
|
||||
type BondPosition,
|
||||
MIN_BOND_SEARCH_LENGTH,
|
||||
useBond,
|
||||
useBondCandles,
|
||||
|
||||
@@ -68,6 +68,7 @@ function useBrokerageOperations(
|
||||
|
||||
export {
|
||||
type BrokerageOperation,
|
||||
type BrokerageOperationsQuery,
|
||||
brokerageOperationsQueryOptions,
|
||||
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
|
||||
operationCategories,
|
||||
|
||||
@@ -5,4 +5,5 @@ export {
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "./api/useBrokerageOperations";
|
||||
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
|
||||
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
||||
|
||||
+19
-16
@@ -1,19 +1,20 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { hashKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
|
||||
function useInstrumentOperations(
|
||||
uid: string,
|
||||
filter: string,
|
||||
categories: OperationCategory[],
|
||||
import {
|
||||
type BrokerageOperationsQuery,
|
||||
brokerageOperationsQueryOptions,
|
||||
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<{
|
||||
cursors: string[];
|
||||
@@ -22,13 +23,14 @@ function useInstrumentOperations(
|
||||
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||
const client = useQueryClient();
|
||||
const firstPage = { categories, instrumentUid: uid, limit: 20 };
|
||||
|
||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
|
||||
const reset = () => setPagination({ cursors: [], scope });
|
||||
|
||||
const retry = (retryScreen: () => unknown) => {
|
||||
if (
|
||||
isApiErrorCode(
|
||||
@@ -36,10 +38,10 @@ function useInstrumentOperations(
|
||||
ApiErrorCode.InvalidOperationsCursor,
|
||||
)
|
||||
) {
|
||||
setPagination({ cursors: [], scope });
|
||||
reset();
|
||||
void client.invalidateQueries({
|
||||
exact: true,
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
queryKey,
|
||||
});
|
||||
}
|
||||
else void retryScreen();
|
||||
@@ -56,8 +58,9 @@ function useInstrumentOperations(
|
||||
},
|
||||
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
|
||||
operations,
|
||||
reset,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
export { useInstrumentOperations };
|
||||
export { useBrokerageOperationHistory };
|
||||
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
|
||||
type LedgerEntryRowProps = {
|
||||
operation: BrokerageOperation;
|
||||
withDetails?: boolean;
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
tone={payment.tone}
|
||||
value={payment.amount}
|
||||
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 {
|
||||
BrokerageAccount,
|
||||
useBrokerageAccounts,
|
||||
} from "../api/useBrokerageAccounts";
|
||||
|
||||
const VISIBLE_CURRENCY_LIMIT = 2;
|
||||
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
|
||||
account: BrokerageAccount;
|
||||
positionCounts: null
|
||||
| { bonds: number; stocks: number };
|
||||
source?: NonNullable<
|
||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||
>["dependencies"] extends (infer T)[] | undefined
|
||||
? T
|
||||
: never;
|
||||
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
|
||||
summary:
|
||||
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
|
||||
| { state: "empty" }
|
||||
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
||||
|
||||
function brokerageAccountCards(
|
||||
accounts: BrokerageAccount[],
|
||||
dependencies: NonNullable<
|
||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||
>["dependencies"] = [],
|
||||
dependencies: InvestmentResourceMeta["dependencies"] = [],
|
||||
): BrokerageAccountCard[] {
|
||||
return accounts.map((account) => {
|
||||
const source = dependencies.find(
|
||||
|
||||
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
@@ -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 { accounts } = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
|
||||
<BrokerageAccountSummary summary={summary} />
|
||||
|
||||
{!!source?.lastSuccessfulAt && (
|
||||
<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>}
|
||||
<BrokerageAccountSourceStatus source={source} />
|
||||
</Stack>
|
||||
|
||||
{!!positionCounts && (
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "./api/useBrokerageEvents";
|
||||
export { brokerageEventKey } from "./model/event-key";
|
||||
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
||||
export {
|
||||
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
|
||||
imageSrc={instrumentLogoUrl(event.logoName)}
|
||||
initial={(
|
||||
(event.ticker ?? event.displayName).trim()[0] ?? "?"
|
||||
event.ticker.trim()[0] ?? "?"
|
||||
).toUpperCase()}
|
||||
metadata={metadata}
|
||||
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
|
||||
import { brokerageEventKey } from "../../model/event-key";
|
||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||
|
||||
type BrokerageEventsPreviewProps = {
|
||||
@@ -32,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
const { events } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorText
|
||||
= events.state === "loaded" && events.refreshError
|
||||
? t("accounts.refreshError")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{events.state === "loaded" && (
|
||||
<>
|
||||
{!!events.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
||||
</>
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={events.lastSuccessfulAt}
|
||||
stale={events.stale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.state === "loading" && (
|
||||
@@ -66,15 +64,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
&& events.items.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
key={brokerageEventKey(event)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
|
||||
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<
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
|
||||
|
||||
export {
|
||||
type IncomeAnalytics,
|
||||
type IncomeAnalyticsMeta,
|
||||
type IncomeSummary,
|
||||
useIncomeAnalytics,
|
||||
useIncomeSummary,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
type IncomeAnalytics,
|
||||
type IncomeAnalyticsMeta,
|
||||
type IncomeSummary,
|
||||
useIncomeAnalytics,
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
unwrapResourceResult,
|
||||
useAuthenticatedQueries,
|
||||
useResourceQuery,
|
||||
} 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;
|
||||
|
||||
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
type StockPeriod = NonNullable<
|
||||
NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"]
|
||||
@@ -16,35 +20,28 @@ type StockPeriod = NonNullable<
|
||||
|
||||
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) {
|
||||
const isUid
|
||||
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
identifier,
|
||||
);
|
||||
|
||||
const byUid = useResourceQuery({
|
||||
...stockQueryOptions(identifier),
|
||||
enabled: Boolean(identifier) && isUid,
|
||||
});
|
||||
// eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
|
||||
const init = isUid
|
||||
? { params: { path: { uid: identifier } } }
|
||||
: { params: { path: { ticker: identifier } } };
|
||||
|
||||
const byTicker = useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
|
||||
params: { path: { ticker: identifier } },
|
||||
}),
|
||||
enabled: Boolean(identifier) && !isUid,
|
||||
const options = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}",
|
||||
init,
|
||||
);
|
||||
|
||||
return useResourceQuery({
|
||||
...options,
|
||||
enabled: Boolean(identifier),
|
||||
staleTime: STOCK_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
return isUid ? byUid : byTicker;
|
||||
}
|
||||
|
||||
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) {
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
|
||||
@@ -127,13 +108,14 @@ function useStockSearch(query: string) {
|
||||
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockPeriod,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
useStock,
|
||||
useStockCandles,
|
||||
useStockCompany,
|
||||
useStockDividends,
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
useStocks,
|
||||
useStockSearch,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockPeriod,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
useStock,
|
||||
useStockCandles,
|
||||
useStockCompany,
|
||||
useStockDividends,
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
useStocks,
|
||||
useStockSearch,
|
||||
} from "./api/queries";
|
||||
|
||||
@@ -3,12 +3,11 @@ import { createContext, use } from "react";
|
||||
import type { paths } from "@/shared/api";
|
||||
|
||||
type AuthContextValue = {
|
||||
accessToken: string | undefined;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
ready: boolean;
|
||||
refresh: () => Promise<boolean>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
signedOut: boolean;
|
||||
user: undefined | User;
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
it("authorizes requests after restoring the initial session", async () => {
|
||||
let authorization: null | string
|
||||
@@ -77,7 +103,7 @@ describe("AuthProvider", () => {
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.accessToken).toBeUndefined();
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
});
|
||||
@@ -106,13 +132,13 @@ describe("AuthProvider", () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.accessToken).toBe("restored-token"),
|
||||
expect(result.current.ready).toBe(true),
|
||||
);
|
||||
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(response.response.status).toBe(401);
|
||||
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(bankCardsKey)).toBeUndefined();
|
||||
});
|
||||
@@ -147,7 +173,7 @@ describe("AuthProvider", () => {
|
||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
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([
|
||||
{ id: "bank-card-1" },
|
||||
@@ -155,7 +181,7 @@ describe("AuthProvider", () => {
|
||||
await act(() => result.current.logout());
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.accessToken).toBeUndefined();
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(statusKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
@@ -265,7 +291,7 @@ describe("AuthProvider", () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
|
||||
await act(() => result.current.refresh());
|
||||
await act(() => expireSession());
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
|
||||
let release!: (value: string) => void;
|
||||
|
||||
@@ -283,7 +309,7 @@ describe("AuthProvider", () => {
|
||||
.catch(() => {});
|
||||
|
||||
identity = "b";
|
||||
await act(() => result.current.refresh());
|
||||
await act(() => expireSession());
|
||||
release("a-data");
|
||||
await pending;
|
||||
expect(result.current.user?.id).toBe("b");
|
||||
@@ -319,12 +345,12 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.cards.data?.[0].id).toBe("a-card"),
|
||||
expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
|
||||
);
|
||||
identity = "b";
|
||||
await act(() => result.current.auth.refresh());
|
||||
await act(() => expireSession());
|
||||
await waitFor(() =>
|
||||
expect(result.current.cards.data?.[0].id).toBe("b-card"),
|
||||
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,9 +29,9 @@ async function request(
|
||||
function useAuthProviderVm() {
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<undefined | User>(undefined);
|
||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
@@ -58,6 +58,7 @@ function useAuthProviderVm() {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
|
||||
setSignedOut(false);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
|
||||
if (changedIdentity) {
|
||||
@@ -69,7 +70,6 @@ function useAuthProviderVm() {
|
||||
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
@@ -78,7 +78,6 @@ function useAuthProviderVm() {
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
setAccessToken(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
@@ -139,6 +138,7 @@ function useAuthProviderVm() {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setSignedOut(true);
|
||||
clearSession();
|
||||
await serialize(async () => {
|
||||
await apiClient.POST("/api/v1/auth/logout");
|
||||
@@ -156,14 +156,13 @@ function useAuthProviderVm() {
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
|
||||
const authContextValue = {
|
||||
accessToken,
|
||||
login: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/login", email, password),
|
||||
logout,
|
||||
ready,
|
||||
refresh,
|
||||
register: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
signedOut,
|
||||
user,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
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 { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
} from "../../model/auth.schema";
|
||||
import classes from "./AuthForm.module.css";
|
||||
|
||||
type AuthFormProps = {
|
||||
onSubmit: (email: string, password: string) => Promise<void>;
|
||||
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
const schema = register ? registerSchema : loginSchema;
|
||||
|
||||
const methods = useForm<AuthFormValues>({
|
||||
resolver: zodResolver(schema) as Resolver<AuthFormValues>,
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
|
||||
const onSubmit2 = methods.handleSubmit(async (values) => {
|
||||
const submit = methods.handleSubmit(async (values) => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit2}>
|
||||
<fieldset className={classes.fieldset} disabled={isSubmitting}>
|
||||
<form onSubmit={submit}>
|
||||
<Fieldset disabled={isSubmitting} variant="unstyled">
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("auth.email")}
|
||||
{...methods.register("email")}
|
||||
error={errors.email?.message as string}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("auth.password")}
|
||||
{...methods.register("password")}
|
||||
error={errors.password?.message as string}
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
|
||||
{register && (
|
||||
<PasswordInput
|
||||
label={t("auth.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")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</Fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAuth } from "../../model/auth-context";
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
import { useLoginFormVm } from "./LoginForm.vm";
|
||||
|
||||
const LoginForm: FC = () => {
|
||||
const { onSubmit } = useLoginFormVm();
|
||||
const { login } = useAuth();
|
||||
|
||||
return <AuthForm onSubmit={onSubmit} />;
|
||||
return <AuthForm onSubmit={login} />;
|
||||
};
|
||||
|
||||
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 { useAuth } from "../../model/auth-context";
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
import { useRegisterFormVm } from "./RegisterForm.vm";
|
||||
|
||||
const RegisterForm: FC = () => {
|
||||
const { onSubmit } = useRegisterFormVm();
|
||||
const { register } = useAuth();
|
||||
|
||||
return <AuthForm onSubmit={onSubmit} register />;
|
||||
return <AuthForm onSubmit={register} register />;
|
||||
};
|
||||
|
||||
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 };
|
||||
@@ -1,6 +1,17 @@
|
||||
export { section } from "./model/section";
|
||||
export { useInstrumentOperations } from "./model/useInstrumentOperations";
|
||||
export { toInstrumentEventRows } from "./model/operation-rows";
|
||||
export { section, type SectionState } from "./model/section";
|
||||
export { useInstrumentParameters } from "./model/useInstrumentParameters";
|
||||
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.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 { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.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";
|
||||
|
||||
type Meta
|
||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
type SectionState = {
|
||||
error: boolean;
|
||||
@@ -20,7 +17,7 @@ function section(
|
||||
data: unknown;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
meta?: Meta;
|
||||
meta?: InvestmentResourceMeta;
|
||||
refetch: () => unknown;
|
||||
},
|
||||
retry?: () => unknown,
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { BondCandles, BondPosition } from "@/entities/bond";
|
||||
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";
|
||||
|
||||
type Candles = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
type Candles = Pick<BondCandles | StockCandles,
|
||||
"candles"
|
||||
| "currency"
|
||||
| "historyFrom"
|
||||
>;
|
||||
|
||||
type ChartRow = Candles["candles"][number] & { date: string; price: number };
|
||||
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
|
||||
|
||||
type InstrumentChartVm = {
|
||||
averageLine: null | number;
|
||||
candles?: Candles;
|
||||
candlesState: SectionState;
|
||||
chartData: ChartRow[];
|
||||
hasIncompleteCandle: boolean;
|
||||
period: string;
|
||||
position?: Position;
|
||||
priceRows: ChartRow[];
|
||||
setPeriod: (value: string) => void;
|
||||
};
|
||||
|
||||
@@ -37,8 +36,6 @@ type InstrumentPositionVm = {
|
||||
positionState: SectionState;
|
||||
};
|
||||
|
||||
type Position = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
type Position = BondPosition | StockPosition;
|
||||
|
||||
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,
|
||||
} 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 { InstrumentSection } from "../InstrumentSection.view";
|
||||
import { useInstrumentChartVm } from "./InstrumentChart.vm";
|
||||
|
||||
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
|
||||
|
||||
const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
const {
|
||||
chartData,
|
||||
chartLabel,
|
||||
formatChartValue,
|
||||
hasIncompleteCandle,
|
||||
periodOptions,
|
||||
priceRows,
|
||||
referenceLines,
|
||||
@@ -34,14 +33,14 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
let chartContent;
|
||||
|
||||
if (vm.candles) {
|
||||
if (vm.chartData.length > 0) {
|
||||
if (chartData.length > 0) {
|
||||
chartContent = (
|
||||
<>
|
||||
<AreaChart
|
||||
areaProps={{ isAnimationActive: false }}
|
||||
aria-label={chartLabel}
|
||||
curveType="linear"
|
||||
data={vm.chartData}
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
gridAxis="y"
|
||||
h={310}
|
||||
@@ -52,7 +51,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
tickLine="none"
|
||||
tooltipAnimationDuration={0}
|
||||
valueFormatter={formatChartValue}
|
||||
withDots={vm.chartData.length === 1}
|
||||
withDots={chartData.length === 1}
|
||||
withGradient
|
||||
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
|
||||
/>
|
||||
@@ -65,13 +64,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!!vm.hasIncompleteCandle && (
|
||||
{!!hasIncompleteCandle && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.incomplete")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{vm.averageLine !== null && (
|
||||
{referenceLines.length > 0 && (
|
||||
<Text mt="xs" size="sm">
|
||||
{t("stock.averageLine")}:{" "}
|
||||
{formatMoney(vm.position?.averagePurchasePrice)}
|
||||
@@ -88,7 +87,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
<Text fw={600}>{t("stock.priceTable")}</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.priceTableHint", { count: vm.chartData.length })}
|
||||
{t("stock.priceTableHint", { count: chartData.length })}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
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) {
|
||||
const { vm } = props;
|
||||
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 value = candle.close;
|
||||
|
||||
@@ -23,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
|
||||
return { ...candle, price };
|
||||
});
|
||||
|
||||
const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({
|
||||
const periodOptions = instrumentPeriods.map(value => ({
|
||||
label: t(`stock.periods.${value}`),
|
||||
value,
|
||||
}));
|
||||
@@ -39,20 +52,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
|
||||
);
|
||||
|
||||
const referenceLines
|
||||
= vm.averageLine === null
|
||||
= averageLine === null || !Number.isFinite(averageLine)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "orange.7",
|
||||
ifOverflow: "extendDomain" as const,
|
||||
label: t("stock.averageLine"),
|
||||
y: vm.averageLine,
|
||||
y: averageLine,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
chartData,
|
||||
chartLabel,
|
||||
formatChartValue,
|
||||
hasIncompleteCandle: chartData.some(candle => !candle.isComplete),
|
||||
periodOptions,
|
||||
priceRows,
|
||||
referenceLines,
|
||||
|
||||
@@ -60,3 +60,31 @@
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { SectionState } from "../model/section";
|
||||
|
||||
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
{props.controls}
|
||||
</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.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.state.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user