chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
на основе `PropsWithChildren<{ … }>`; без props — `FC`. на основе `PropsWithChildren<{ … }>`; без props — `FC`.
- Обычные exports — нижний named block; barrel делает прямой re-export. - Обычные exports — нижний named block; barrel делает прямой re-export.
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [Biome](../../biome.json). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
## Hooks и derived state ## Hooks и derived state
@@ -41,8 +41,8 @@ Backend vocabulary бери из generated `paths`, не создавай руч
enum и не превращай неизвестный code в известный через cast. Повторяющуюся enum и не превращай неизвестный code в известный через cast. Повторяющуюся
обработку своди у ответственного owner; [errors](data.md#errors). обработку своди у ответственного owner; [errors](data.md#errors).
`noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только `@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
из этого правила. Policy получает имя и единицы; подавление допустимо для из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
incidental value с объяснением. Обычный UI-текст не превращай в constant: incidental value с объяснением. Обычный UI-текст не превращай в constant:
его owner — [i18n](ui.md#строки-и-валидация). его owner — [i18n](ui.md#строки-и-валидация).
Semantic magic strings и props profile требуют review. Semantic magic strings и props profile требуют review.
+1 -1
View File
@@ -121,7 +121,7 @@ Types, props, hooks и memoization — в [TypeScript/React](typescript-react.md
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без `useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
пользовательских literal messages. Это mandatory semantic review: общего запрета пользовательских literal messages. Это mandatory semantic review: общего запрета
непереведённых JSX-строк в Biome/TypeScript нет. Synthetic story fixtures и непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и
описания историй допустимы; они не заменяют продуктовую локализацию. описания историй допустимы; они не заменяют продуктовую локализацию.
## Проверка ## Проверка
+2 -2
View File
@@ -5,11 +5,11 @@ module.exports = {
forbidden: [ forbidden: [
...base.forbidden, ...base.forbidden,
{ {
from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` },
name: "stories-must-not-reach-application-data", name: "stories-must-not-reach-application-data",
severity: "error", severity: "error",
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
to: { to: {
path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/.*Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/", path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
reachable: true, reachable: true,
}, },
}, },
+2 -2
View File
@@ -1,10 +1,10 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"], addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true }, core: { disableTelemetry: true },
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
}; };
export default config; export default config;
+17 -15
View File
@@ -3,37 +3,30 @@ import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@wealthledger/ui/styles.css"; import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import "../src/shared/i18n"; import "../src/shared/i18n";
import type { Preview } from "@storybook/react-vite";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import type { Preview } from "@storybook/react-vite";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = { const colorSchemeManager = {
clear: () => {},
get: () => "light" as const, get: () => "light" as const,
set: () => {}, set: () => {},
clear: () => {},
subscribe: () => {}, subscribe: () => {},
unsubscribe: () => {}, unsubscribe: () => {},
}; };
const preview: Preview = { const preview: Preview = {
parameters: {
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
controls: { expanded: true },
},
decorators: [ decorators: [
(Story) => ( Story => (
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager} colorSchemeManager={colorSchemeManager}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme}
> >
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<Story /> <Story />
@@ -41,6 +34,15 @@ const preview: Preview = {
</MantineProvider> </MantineProvider>
), ),
], ],
parameters: {
controls: { expanded: true },
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
},
}; };
export default preview; export default preview;
+1 -1
View File
@@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice | | Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) | | Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения | | Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
| Versions/scripts/check scope | Manifest/lockfile, ESLint/Biome/TypeScript/dependency configs | | Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs |
+18 -21
View File
@@ -81,32 +81,29 @@ npm run verify
``` ```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, ESLint и проверку его конфигурации, затем запускает strict TypeScript, ESLint и проверку его конфигурации,
один coverage-backed запуск Vitest, один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook. Knip, dependency rules приложения и stories, сборки приложения и Storybook.
`npm run lint` объединяет `lint:biome` и `lint:eslint`. Biome сохраняет `npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix`
форматирование, сортировку импортов, magic numbers и accessibility-проверки. применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
[ESLint](eslint.config.mjs) добавляет typed linting, React Compiler/hooks, наборы всех установленных плагинов: TypeScript, React, Hooks, Refresh, Query,
Fast Refresh, TanStack Query, выбранные Unicorn-правила и проверки тестов/stories. Unicorn, import-x, SonarJS, RegExp, Security, Perfectionist, Stylistic,
Prettier не добавлен. `npm run test:lint` проверяет правила на ошибочных примерах Vitest, Testing Library, jest-dom и Storybook. Правила React, тестов и stories
и исключение generated/build artifacts. `lint:eslint` сначала собирает private применяются к соответствующим файлам; generated/build artifacts исключены.
packages, чтобы анализ типов использовал их актуальные declarations.
Пересекающиеся hook-диагностики принадлежат официальному `react-hooks`. Stylistic отвечает за форматирование JS/TS/TSX, Perfectionist — за сортировку.
Testing Library проверяет async-корректность; DOM structure/geometry tests Сохраняются табы, двойные кавычки и точки с запятой. Biome удалён; Prettier
сохраняют прямой доступ к узлам. Async doubles допускаются без искусственного не используется. CSS, JSON и Markdown не форматируются через ESLint.
`await`; Vitest допускает второй аргумент сообщения и ветвления фиксированных `lint-staged` исправляет JS/TS/TSX перед commit. `lint:eslint` сначала собирает
`it.each` сценариев. React event handlers допускают Promise, но их тела остаются private packages, чтобы анализ типов использовал актуальные declarations.
под `no-floating-promises`. Локальные исключения объясняют сохранение wire `npm run test:lint` проверяет действующие presets и ошибочные примеры.
ProblemDetails, generated query keys и раннее подключение rejection assertions
в race-тестах. Плагин Query не доказывает корректность всех generated/custom
обёрток: transport/cache сценарии по-прежнему проверяют MSW-тесты.
Дополнительные `no-unnecessary-condition` и `no-deprecated` пока не включены: Точечные исключения в исходниках объясняют контракты и ограничения статического
первому нужна отдельная оценка индексного доступа и защитных runtime guards, анализа; изменение nullable API fields, query keys или порядка race-операций
второму — отдельная миграция API зависимостей. Пакеты второй волны и ради линтера недопустимо. Архитектуру проверяет dependency-cruiser, поведение —
`jsx-a11y` без заявленной поддержки ESLint 10 отложены в Vitest/MSW и Chromium. Линтеры не заменяют semantic/security review.
`jsx-a11y` без поддержки ESLint 10 остаётся отложенным в
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18). [workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
-81
View File
@@ -1,81 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!dist",
"!packages/*/dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
"!coverage",
"!node_modules"
]
},
"linter": {
"rules": {
"preset": "recommended",
"nursery": {
"useReactFunctionComponentDefinition": {
"level": "error",
"options": {
"namedComponents": "arrowFunction"
}
}
},
"style": {
"noDefaultExport": "error",
"useExportsLast": "error",
"noMagicNumbers": "error"
}
}
},
"overrides": [
{
"includes": [
"eslint.config.mjs",
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
".storybook/preview.tsx",
"packages/*/vite.config.ts"
],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
},
{
"includes": ["**/*.stories.tsx"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off",
"useExportsLast": "off"
}
}
}
},
{
"includes": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.stories.tsx",
"src/test/**"
],
"linter": {
"rules": {
"style": {
"noMagicNumbers": "off"
}
}
}
}
]
}
+14 -14
View File
@@ -1,73 +1,73 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{ {
from: {},
name: "no-unresolved", name: "no-unresolved",
severity: "error", severity: "error",
from: {},
to: { couldNotResolve: true }, to: { couldNotResolve: true },
}, },
{ {
from: { path: "^packages/" },
name: "packages-must-not-depend-on-app", name: "packages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^packages/" },
to: { path: "^src/" }, to: { path: "^src/" },
}, },
{ {
from: {},
name: "no-circular", name: "no-circular",
severity: "error", severity: "error",
from: {},
to: { circular: true }, to: { circular: true },
}, },
{ {
name: "slices-must-use-public-api",
severity: "error",
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api",
severity: "error",
to: { to: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)", path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
}, },
{ {
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
from: { path: "^src/pages/" },
name: "pages-must-not-depend-on-app", name: "pages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" }, to: { path: "^src/app/" },
}, },
], ],
options: { options: {
exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" }, doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
enhancedResolveOptions: { enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"], conditionNames: ["import", "require", "node", "default"],
exportsFields: ["exports"],
}, },
exclude: "^packages/[^/]+/dist/",
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
}, },
}; };
+63 -77
View File
@@ -1,131 +1,117 @@
import js from "@eslint/js";
import react from "@eslint-react/eslint-plugin"; import react from "@eslint-react/eslint-plugin";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query"; import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin"; import vitest from "@vitest/eslint-plugin";
import { defineConfig, globalIgnores } from "eslint/config"; import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x"; import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks"; import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh"; import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook"; import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library"; import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn"; import unicorn from "eslint-plugin-unicorn";
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals"; import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default defineConfig( export default defineConfig(
globalIgnores([ globalIgnores([
"**/dist/**", "**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
"dist-ssr/**", ".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]), ]),
{ {
extends: [
js.configs.recommended,
importX.flatConfigs.recommended,
unicorn.configs.recommended,
sonarjs.configs.recommended,
regexp.configs.recommended,
security.configs.recommended,
perfectionist.configs["recommended-natural"],
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
],
files: ["**/*.{js,mjs,cjs,ts,tsx}"], files: ["**/*.{js,mjs,cjs,ts,tsx}"],
extends: [js.configs.recommended],
linterOptions: { reportUnusedDisableDirectives: "error" }, linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": ["error", {
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
objects: "always-multiline", tuples: "always-multiline",
}],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
// Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }],
// React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }],
// React, Mantine and Fetch accept null at their documented call boundaries.
"unicorn/no-null": ["error", { checkArguments: false }],
},
settings: {
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })],
},
}, },
{ {
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"], files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
languageOptions: { globals: globals.node }, languageOptions: { globals: globals.node },
}, },
{ {
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
extends: [tseslint.configs.recommendedTypeChecked], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: { rules: {
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
// React ignores handler return values; their bodies still check floating promises.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
}, },
}, },
{ {
files: [ extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
"src/**/*.{ts,tsx}", files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
extends: [
react.configs["recommended-type-checked"],
react.configs["disable-naming-convention"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
languageOptions: { globals: globals.browser }, languageOptions: { globals: globals.browser },
plugins: { "import-x": importX, unicorn },
rules: { rules: {
// The official hooks plugin owns Compiler and hook diagnostics.
"@eslint-react/error-boundaries": "off",
"@eslint-react/exhaustive-deps": "off",
"@eslint-react/purity": "off",
"@eslint-react/rules-of-hooks": "off",
"@eslint-react/set-state-in-effect": "off",
"@eslint-react/set-state-in-render": "off",
"@eslint-react/static-components": "off",
"@eslint-react/unsupported-syntax": "off",
"@eslint-react/use-memo": "off",
// useContext remains supported; a lint rollout does not require an API migration.
"@eslint-react/no-use-context": "off",
"import-x/no-default-export": "error",
"import-x/exports-last": "error", "import-x/exports-last": "error",
// Biome owns formatting, import sorting and magic numbers during coexistence. "import-x/no-default-export": "error",
"unicorn/no-array-fill-with-reference-type": "error",
"unicorn/no-await-in-promise-methods": "error",
"unicorn/no-single-promise-in-promise-methods": "error",
"unicorn/prefer-array-some": "error",
"unicorn/prefer-includes": "error",
}, },
}, },
{ {
extends: [refresh.configs.vite],
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
extends: [refresh.configs.vite],
}, },
{ {
files: ["**/*.test.{ts,tsx}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": ["error", {
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
}],
},
},
{
extends: [vitest.configs.recommended], extends: [vitest.configs.recommended],
files: ["**/*.test.{ts,tsx}"],
rules: { rules: {
"vitest/no-focused-tests": "error", "vitest/no-focused-tests": "error",
"vitest/valid-expect": ["error", { maxArgs: 2 }], "vitest/valid-expect": ["error", { maxArgs: 2 }],
// it.each cases select different expected outcomes and recovery setup steps.
"vitest/no-conditional-expect": "off",
// Async test doubles preserve the public Promise contract without artificial awaits.
"@typescript-eslint/require-await": "off",
}, },
}, },
{ {
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
files: ["**/*.test.tsx"], files: ["**/*.test.tsx"],
plugins: { "testing-library": testingLibrary },
// Start with async correctness. DOM structure/geometry tests need direct node access.
rules: {
"testing-library/await-async-events": "error",
"testing-library/await-async-queries": "error",
"testing-library/await-async-utils": "error",
"testing-library/no-await-sync-events": "error",
"testing-library/no-await-sync-queries": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-side-effects": "error",
"testing-library/no-wait-for-snapshot": "error",
"testing-library/prefer-find-by": "error",
},
}, },
storybook.configs["flat/recommended"], storybook.configs["flat/recommended"],
{ {
files: ["**/*.stories.tsx", ".storybook/preview.tsx"], files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: { rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
"import-x/no-default-export": "off",
"import-x/exports-last": "off",
},
}, },
); );
+381 -176
View File
@@ -36,7 +36,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.5", "@babel/core": "8.0.5",
"@biomejs/biome": "2.5.13",
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.21.0", "@eslint-react/eslint-plugin": "5.21.0",
@@ -45,6 +44,7 @@
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.0",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0", "@tanstack/eslint-plugin-query": "5.104.0",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
@@ -59,9 +59,15 @@
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.2.0",
"eslint": "10.11.0", "eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7", "eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-storybook": "10.6.0", "eslint-plugin-storybook": "10.6.0",
"eslint-plugin-testing-library": "7.16.2", "eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0", "eslint-plugin-unicorn": "76.0.0",
@@ -693,181 +699,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@biomejs/biome": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.13.tgz",
"integrity": "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.5.13",
"@biomejs/cli-darwin-x64": "2.5.13",
"@biomejs/cli-linux-arm64": "2.5.13",
"@biomejs/cli-linux-arm64-musl": "2.5.13",
"@biomejs/cli-linux-x64": "2.5.13",
"@biomejs/cli-linux-x64-musl": "2.5.13",
"@biomejs/cli-win32-arm64": "2.5.13",
"@biomejs/cli-win32-x64": "2.5.13"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.13.tgz",
"integrity": "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.13.tgz",
"integrity": "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.13.tgz",
"integrity": "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.13.tgz",
"integrity": "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.13.tgz",
"integrity": "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.13.tgz",
"integrity": "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.13.tgz",
"integrity": "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.13.tgz",
"integrity": "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@blazediff/core": { "node_modules/@blazediff/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz",
@@ -5235,6 +5066,13 @@
"url": "https://ko-fi.com/dangreen" "url": "https://ko-fi.com/dangreen"
} }
}, },
"node_modules/@sonarsource/analyzer-commons-configurations": {
"version": "2.31.0-5284",
"resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz",
"integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==",
"dev": true,
"license": "LGPL-3.0-only"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5407,6 +5245,58 @@
} }
} }
}, },
"node_modules/@stylistic/eslint-plugin": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz",
"integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/types": "^8.56.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": "^9.0.0 || ^10.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query": { "node_modules/@tanstack/eslint-plugin-query": {
"version": "5.104.0", "version": "5.104.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.104.0.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.104.0.tgz",
@@ -7160,6 +7050,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/c12": { "node_modules/c12": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -8853,6 +8753,41 @@
} }
} }
}, },
"node_modules/eslint-import-resolver-typescript": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz",
"integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==",
"dev": true,
"license": "ISC",
"dependencies": {
"debug": "^4.4.1",
"eslint-import-context": "^0.1.8",
"get-tsconfig": "^4.10.1",
"is-bun-module": "^2.0.0",
"stable-hash-x": "^0.2.0",
"tinyglobby": "^0.2.14",
"unrs-resolver": "^1.7.11"
},
"engines": {
"node": "^16.17.0 || >=18.6.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-resolver-typescript"
},
"peerDependencies": {
"eslint": "*",
"eslint-plugin-import": "*",
"eslint-plugin-import-x": "*"
},
"peerDependenciesMeta": {
"eslint-plugin-import": {
"optional": true
},
"eslint-plugin-import-x": {
"optional": true
}
}
},
"node_modules/eslint-plugin-import-x": { "node_modules/eslint-plugin-import-x": {
"version": "4.17.1", "version": "4.17.1",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz",
@@ -8903,6 +8838,53 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/eslint-plugin-jest-dom": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest-dom/-/eslint-plugin-jest-dom-5.10.1.tgz",
"integrity": "sha512-IIJdzbACbhJUJyMAqpA9E9gxp1Gv29TeQ3DtL+pepwP8Oq2WiqjOB2HbnEIs23h0ewdKIZRDI56yLro2eUO+DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"requireindex": "^1.2.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0",
"npm": ">=6",
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": "^8.0.0 || ^9.0.0 || ^10.0.0",
"eslint": "^6.8.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
},
"peerDependenciesMeta": {
"@testing-library/dom": {
"optional": true
}
}
},
"node_modules/eslint-plugin-perfectionist": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.12.1.tgz",
"integrity": "sha512-krdSE7Dz+TCtcVr2+rfNhlOIFFBJcUdoRvjaN7JQnsaVnxJw+BZh9wMkY3MSLCJat+yW+UPTQLKS82Epc7MGZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "^8.70.0",
"@typescript-eslint/utils": "^8.70.0",
"natural-orderby": "^5.0.0"
},
"engines": {
"node": "^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"eslint": "^8.45.0 || ^9.0.0 || ^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/eslint-plugin-react-dom": { "node_modules/eslint-plugin-react-dom": {
"version": "5.21.0", "version": "5.21.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.21.0.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.21.0.tgz",
@@ -9222,6 +9204,96 @@
} }
} }
}, },
"node_modules/eslint-plugin-regexp": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.3.1.tgz",
"integrity": "sha512-58bGUci/AaWAo1ETj6Lsnmy6QXwnSyVONRA805828nnqp7vrTwdaQMc02vtowmYB3A5OB3SLr3r5HgmfxX4dLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.11.0",
"comment-parser": "^1.4.0",
"jsdoc-type-pratt-parser": "^9.0.0",
"refa": "^0.12.1",
"regexp-ast-analysis": "^0.7.1",
"scslre": "^0.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"peerDependencies": {
"eslint": ">=9.38.0"
}
},
"node_modules/eslint-plugin-security": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.1.0.tgz",
"integrity": "sha512-cB4H72o/w8C0cm4uHJOnXMJz7MYTTuOwhn2X7PMuzZwfwqUoHK/ASHT6WKP+95lFk5Hkf3xfpiXB8a1+cef4rg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"safe-regex": "^2.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-sonarjs": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.1.tgz",
"integrity": "sha512-WGsnVkUxmHyBOytShcIeE+vhQaS8e+gYgbHNNi4y2e5Zj+DpE2DLTvarqDUhNwjceTZHGLgevSgfBSEVkt4EaA==",
"dev": true,
"license": "LGPL-3.0-only",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@sonarsource/analyzer-commons-configurations": "^2.31.0-5284",
"builtin-modules": "^3.3.0",
"bytes": "^3.1.2",
"functional-red-black-tree": "^1.0.1",
"globals": "^17.12.0",
"jsx-ast-utils-x": "^0.1.0",
"lodash.merge": "^4.6.2",
"minimatch": "^10.2.6",
"scslre": "^0.3.0",
"semver": "^7.8.5",
"ts-api-utils": "^2.5.0",
"typescript": ">=5 <6.1.0",
"yaml": "^2.9.0"
},
"peerDependencies": {
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-storybook": { "node_modules/eslint-plugin-storybook": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-storybook/-/eslint-plugin-storybook-10.6.0.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-storybook/-/eslint-plugin-storybook-10.6.0.tgz",
@@ -9883,6 +9955,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"dev": true,
"license": "MIT"
},
"node_modules/functions-have-names": { "node_modules/functions-have-names": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
@@ -10715,6 +10794,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
"integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.7.1"
}
},
"node_modules/is-bun-module/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/is-callable": { "node_modules/is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -11390,6 +11492,37 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsdoc-type-pratt-parser": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz",
"integrity": "sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.9",
"@types/node": "^26.4.0"
},
"engines": {
"node": "^22.22.2 || >=24.15.0"
}
},
"node_modules/jsdoc-type-pratt-parser/node_modules/@types/node": {
"version": "26.6.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
"integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/jsdoc-type-pratt-parser/node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "30.0.1", "version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
@@ -11515,6 +11648,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/jsx-ast-utils-x": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz",
"integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/keyv/-/keyv-5.6.0.tgz", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/keyv/-/keyv-5.6.0.tgz",
@@ -12453,6 +12596,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-orderby": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz",
"integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/netmask": { "node_modules/netmask": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
@@ -13893,6 +14046,19 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/refa": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz",
"integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.8.0"
},
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -13936,6 +14102,20 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/regexp-ast-analysis": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz",
"integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.8.0",
"refa": "^0.12.1"
},
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/regexp-tree": { "node_modules/regexp-tree": {
"version": "0.1.27", "version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -14146,6 +14326,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.5"
}
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
@@ -14446,6 +14636,21 @@
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/scslre": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz",
"integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.8.0",
"refa": "^0.12.0",
"regexp-ast-analysis": "^0.7.0"
},
"engines": {
"node": "^14.0.0 || >=16.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+11 -5
View File
@@ -13,8 +13,7 @@
"preview:catalog": "vite preview --outDir storybook-static", "preview:catalog": "vite preview --outDir storybook-static",
"typecheck": "npm run build:packages && tsc -b --noEmit", "typecheck": "npm run build:packages && tsc -b --noEmit",
"build": "npm run build:packages && tsc -b && vite build", "build": "npm run build:packages && tsc -b && vite build",
"lint": "npm run lint:biome && npm run lint:eslint", "lint": "npm run lint:eslint",
"lint:biome": "biome check .",
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0", "lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
"test": "npm run build:packages && vitest", "test": "npm run build:packages && vitest",
"test:run": "npm run build:packages && vitest run", "test:run": "npm run build:packages && vitest run",
@@ -31,10 +30,11 @@
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint", "commitlint": "commitlint",
"build:packages": "npm run build --workspaces --if-present" "build:packages": "npm run build --workspaces --if-present",
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
}, },
"lint-staged": { "lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
@@ -62,7 +62,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.5", "@babel/core": "8.0.5",
"@biomejs/biome": "2.5.13",
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.21.0", "@eslint-react/eslint-plugin": "5.21.0",
@@ -71,6 +70,7 @@
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.0",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0", "@tanstack/eslint-plugin-query": "5.104.0",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
@@ -85,9 +85,15 @@
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.2.0",
"eslint": "10.11.0", "eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7", "eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-storybook": "10.6.0", "eslint-plugin-storybook": "10.6.0",
"eslint-plugin-testing-library": "7.16.2", "eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0", "eslint-plugin-unicorn": "76.0.0",
+11 -10
View File
@@ -1,10 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon } from "./index";
const meta = { const meta = {
title: "Design System/Icons",
component: AddIcon, component: AddIcon,
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -13,6 +12,8 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Icons",
} satisfies Meta<typeof AddIcon>; } satisfies Meta<typeof AddIcon>;
export default meta; export default meta;
@@ -20,14 +21,14 @@ type Story = StoryObj<typeof meta>;
export const Decorative: Story = { export const Decorative: Story = {
render: () => ( render: () => (
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<button type="button" aria-label="Добавить пример"> <button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} /> <AddIcon size={20} strokeWidth={2} />
</button> </button>
<button type="button" aria-label="Изменить пример"> <button aria-label="Изменить пример" type="button">
<EditIcon size={18} /> <EditIcon size={18} />
</button> </button>
<button type="button" aria-label="Удалить пример"> <button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} /> <DeleteIcon size={18} />
</button> </button>
</div> </div>
@@ -36,10 +37,10 @@ export const Decorative: Story = {
export const Meaningful: Story = { export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} role="img" aria-label="Добавление" /> <AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" /> <EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" /> <DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
}; };
+4 -2
View File
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
@@ -7,19 +8,20 @@ describe("icon accessibility", () => {
"keeps decoration hidden and exposes explicitly named images: %s", "keeps decoration hidden and exposes explicitly named images: %s",
(Icon) => { (Icon) => {
const { rerender } = render( const { rerender } = render(
<button type="button" aria-label="Действие"> <button aria-label="Действие" type="button">
<Icon /> <Icon />
</button>, </button>,
); );
expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect( expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("button", { name: "Действие" }).querySelector("svg"), screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
).toHaveAttribute("focusable", "false"); ).toHaveAttribute("focusable", "false");
rerender( rerender(
<Icon <Icon
aria-hidden={false} aria-hidden={false}
role="img"
aria-label="Пример" aria-label="Пример"
role="img"
size={18} size={18}
strokeWidth={2} strokeWidth={2}
/>, />,
+8 -7
View File
@@ -1,19 +1,20 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const AddIcon: FC<IconProps> = (props) => { const AddIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true" aria-hidden="true"
fill="none"
focusable="false" focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M12 5v14 M5 12h14" /> <path d="M12 5v14 M5 12h14" />
+7 -6
View File
@@ -1,20 +1,21 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const DeleteIcon: FC<IconProps> = (props) => { const DeleteIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" /> <path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
+7 -6
View File
@@ -1,20 +1,21 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => { const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" /> <path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" }, lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] },
rolldownOptions: { external: /^react(\/|$)/ }, rolldownOptions: { external: /^react(\/|$)/ },
}, },
}); });
+15 -13
View File
@@ -1,27 +1,27 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
const meta = { const meta = {
title: "Design System/Components/EntryCard",
component: EntryCard,
tags: ["autodocs"],
args: { args: {
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
metadata: "22.09.2026 · Синтетические данные",
initial: "У",
imageSrc: "data:image/png;base64,invalid",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
tone: "negative",
actions: ( actions: (
<Button size="compact-sm" onClick={fn()}> <Button onClick={fn()} size="compact-sm">
Открыть пример Открыть пример
</Button> </Button>
), ),
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "22.09.2026 · Синтетические данные",
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
tone: "negative",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
}, },
component: EntryCard,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -30,6 +30,8 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Components/EntryCard",
} satisfies Meta<typeof EntryCard>; } satisfies Meta<typeof EntryCard>;
export default meta; export default meta;
+13 -12
View File
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { assert, describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
describe("EntryCard", () => { describe("EntryCard", () => {
@@ -8,13 +9,13 @@ describe("EntryCard", () => {
render( render(
<MantineProvider> <MantineProvider>
<EntryCard <EntryCard
title="Пример" actions={0}
metadata="Описание"
initial="У" initial="У"
metadata="Описание"
title="Пример"
tone="neutral"
value="—" value="—"
valueLabel="Неизвестно" valueLabel="Неизвестно"
tone="neutral"
actions={0}
/> />
</MantineProvider>, </MantineProvider>,
); );
@@ -22,21 +23,21 @@ describe("EntryCard", () => {
}); });
it("keeps a failed image fallback until a new source is supplied", () => { it("keeps a failed image fallback until a new source is supplied", () => {
const props = { const props = {
title: "Пример", imageSrc: "data:image/png;base64,invalid",
metadata: "Описание",
initial: "У", initial: "У",
metadata: "Описание",
title: "Пример",
tone: "neutral" as const,
value: "—", value: "—",
valueLabel: "Неизвестно", valueLabel: "Неизвестно",
tone: "neutral" as const,
imageSrc: "data:image/png;base64,invalid",
}; };
const { container, rerender } = render( const { rerender } = render(
<MantineProvider> <MantineProvider>
<EntryCard {...props} /> <EntryCard {...props} />
</MantineProvider>, </MantineProvider>,
); );
// Decorative image has no accessible name; trigger its actual failure event. // Decorative image has no accessible name; trigger its actual failure event.
const image = container.querySelector("img"); const image = screen.queryByAltText("");
assert(image); assert(image);
fireEvent.error(image); fireEvent.error(image);
rerender( rerender(
@@ -44,7 +45,7 @@ describe("EntryCard", () => {
<EntryCard {...props} title="Обновлённый пример" /> <EntryCard {...props} title="Обновлённый пример" />
</MantineProvider>, </MantineProvider>,
); );
expect(container.querySelector("img")).toBeNull(); expect(screen.queryByAltText("")).not.toBeInTheDocument();
expect(screen.getByText("У")).toBeInTheDocument(); expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender( rerender(
@@ -52,7 +53,7 @@ describe("EntryCard", () => {
<EntryCard {...props} imageSrc="/replacement-logo.png" /> <EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>, </MantineProvider>,
); );
expect(container.querySelector("img")).toHaveAttribute( expect(screen.queryByAltText("")).toHaveAttribute(
"src", "src",
"/replacement-logo.png", "/replacement-logo.png",
); );
+15 -13
View File
@@ -1,33 +1,35 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import classes from "./EntryCard.module.css"; import classes from "./EntryCard.module.css";
type EntryCardProps = { type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string; color?: string;
imageSrc?: string | null; details?: ReactNode;
icon?: ReactNode;
imageSrc?: null | string;
initial: string;
metadata: string;
title: string;
tone: "negative" | "neutral" | "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
tone: "positive" | "negative" | "neutral";
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
return ( return (
<Paper <Paper
className={classes.row} className={classes.row}
withBorder
style={ style={
props.color (props.color)
? { ? ({
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`, background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
} })
: undefined : undefined
} }
withBorder
> >
<Avatar <Avatar
alt="" alt=""
@@ -51,7 +53,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
<Text className={classes[props.tone]} fw={700}> <Text className={classes[props.tone]} fw={700}>
{props.value} {props.value}
</Text> </Text>
<div className={props.actions ? classes.valueFooter : undefined}> <div className={(props.actions) ? (classes.valueFooter) : undefined}>
<Text <Text
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`} className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm" size="sm"
@@ -1,16 +1,16 @@
import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Paper, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "./LoadingSkeleton"; import { LoadingSkeleton } from "./LoadingSkeleton";
const meta = { const meta = {
title: "Design System/Components/LoadingSkeleton",
component: LoadingSkeleton,
tags: ["autodocs"],
args: { label: "Загрузка содержимого" }, args: { label: "Загрузка содержимого" },
argTypes: { argTypes: {
label: { control: "text" },
children: { table: { disable: true } }, children: { table: { disable: true } },
label: { control: "text" },
}, },
component: LoadingSkeleton,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -19,15 +19,17 @@ const meta = {
}, },
}, },
}, },
render: (args) => ( render: arguments_ => (
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...args}> <LoadingSkeleton {...arguments_}>
<Skeleton h={24} w="60%" /> <Skeleton h={24} w="60%" />
<Skeleton h={72} /> <Skeleton h={72} />
<Skeleton h={16} w="80%" /> <Skeleton h={16} w="80%" />
</LoadingSkeleton> </LoadingSkeleton>
</Paper> </Paper>
), ),
tags: ["autodocs"],
title: "Design System/Components/LoadingSkeleton",
} satisfies Meta<typeof LoadingSkeleton>; } satisfies Meta<typeof LoadingSkeleton>;
export default meta; export default meta;
@@ -1,6 +1,7 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Stack } from "@mantine/core";
type LoadingSkeletonProps = PropsWithChildren<{ type LoadingSkeletonProps = PropsWithChildren<{
label: string; label: string;
}>; }>;
+29 -25
View File
@@ -1,36 +1,36 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { Button } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & { type StateStoryArguments = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void; onAction: () => void;
withAction: boolean;
}; };
const meta = { const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: { args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false, error: false,
withAction: false,
onAction: fn(), onAction: fn(),
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
title: "Нет учебных данных",
withAction: false,
}, },
argTypes: { argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } }, action: { table: { disable: true } },
error: { control: "boolean" },
onAction: { control: false },
text: { control: "text" },
title: { control: "text" },
withAction: { withAction: {
control: "boolean", control: "boolean",
description: "Показать необязательное действие StateView", description: "Показать необязательное действие StateView",
}, },
onAction: { control: false },
}, },
component: StateView,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -39,40 +39,44 @@ const meta = {
}, },
}, },
}, },
render: (args) => { render: (arguments_) => {
const { withAction, onAction, ...stateProps } = args; const { onAction, withAction, ...stateProps } = arguments_;
return ( return (
<StateView <StateView
{...stateProps} {...stateProps}
action={ action={
withAction ? ( (withAction)
<Button onClick={onAction} variant="default"> ? ((
{args.error ? "Повторить локально" : "Выполнить пример"} <Button onClick={onAction} variant="default">
</Button> {arguments_.error ? "Повторить локально" : "Выполнить пример"}
) : undefined </Button>
))
: undefined
} }
/> />
); );
}, },
} satisfies Meta<StateStoryArgs>; tags: ["autodocs"],
title: "Design System/Components/StateView",
} satisfies Meta<StateStoryArguments>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" }; export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = { export const EmptyWithAction: Story = {
name: "Пустое состояние с действием",
args: { withAction: true }, args: { withAction: true },
name: "Пустое состояние с действием",
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка",
args: { args: {
error: true, error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.", text: "Демонстрация недоступных данных без вызова API.",
title: "Учебная ошибка",
}, },
name: "Ошибка",
}; };
export const ErrorWithAction: Story = { export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true }, args: { ...Failed.args, withAction: true },
name: "Ошибка с retry",
}; };
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton"; import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
+6 -5
View File
@@ -1,19 +1,20 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
type StateViewProps = { type StateViewProps = {
title: string;
text: string;
action?: ReactNode; action?: ReactNode;
error?: boolean; error?: boolean;
text: string;
title: string;
}; };
const StateView: FC<StateViewProps> = (props) => { const StateView: FC<StateViewProps> = (props) => {
const { title, text, action, error = false } = props; const { action, error = false, text, title } = props;
const role = error ? "alert" : undefined; const role = error ? "alert" : undefined;
return ( return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}> <Paper p="xl" radius="md" role={role} shadow="xs" withBorder>
<Stack align="flex-start" gap="sm"> <Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text> <Text c="dimmed">{text}</Text>
+1
View File
@@ -1,6 +1,7 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core"; import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
import source from "@wealthledger/tokens/tokens.json"; import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme"; import { colorRoles, cssVariablesResolver, theme } from "./theme";
describe("design theme adapter", () => { describe("design theme adapter", () => {
+30 -28
View File
@@ -1,8 +1,8 @@
import "./styles.css"; import "./styles.css";
import { import {
type CSSVariablesResolver,
colorsTuple, colorsTuple,
createTheme, createTheme,
type CSSVariablesResolver,
getThemeColor, getThemeColor,
type MantinePrimaryShade, type MantinePrimaryShade,
Modal, Modal,
@@ -11,78 +11,80 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json"; import designTokens from "@wealthledger/tokens/tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } = const { breakpoints, color, radius, shadows, spacing, typography }
designTokens; = designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) => const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries( Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, convert(value)]), Object.entries(values).map(([name, value]) => [name, convert(value)]),
); );
const colorRoles = mapValues(color.roles, (role) => const colorRoles = mapValues(color.roles, role =>
role.shade === null ? role.color : `${role.color}.${role.shade}`, role.shade === null ? role.color : `${role.color}.${role.shade}`,
); );
const theme = createTheme({ const theme = createTheme({
respectReducedMotion: true, black: color.base.black,
breakpoints: mapValues(
breakpoints.values,
value => `${value}${breakpoints.unit}`,
),
colors: mapValues(color.palettes, colorsTuple),
components: { components: {
Modal: Modal.extend({ Modal: Modal.extend({
defaultProps: { defaultProps: {
overlayProps: { blur: 3 }, overlayProps: { blur: 3 },
transitionProps: { transitionProps: {
transition: "fade-down",
duration: 220, duration: 220,
exitDuration: 180, exitDuration: 180,
timingFunction: "ease-out", timingFunction: "ease-out",
transition: "fade-down",
}, },
}, },
}), }),
}, },
primaryColor: color.roles.primary.color,
primaryShade: color.primaryShade as MantinePrimaryShade,
defaultRadius: radius.default, defaultRadius: radius.default,
white: color.base.white,
black: color.base.black,
colors: mapValues(color.palettes, colorsTuple),
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontFamilyMonospace: typography.fontFamilyMonospace, fontFamilyMonospace: typography.fontFamilyMonospace,
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)), fontSizes: mapValues(typography.fontSizes, value => rem(value)),
fontWeights: mapValues(typography.fontWeights, String), fontWeights: mapValues(typography.fontWeights, String),
lineHeights: mapValues(typography.lineHeights, String),
headings: { headings: {
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontWeight: String(typography.fontWeights.bold), fontWeight: String(typography.fontWeights.bold),
sizes: mapValues(typography.headings, (value) => ({ sizes: mapValues(typography.headings, value => ({
fontSize: rem(value.fontSize), fontSize: rem(value.fontSize),
lineHeight: String(value.lineHeight), lineHeight: String(value.lineHeight),
})), })),
}, },
spacing: mapValues(spacing.values, (value) => rem(value)), lineHeights: mapValues(typography.lineHeights, String),
radius: mapValues(radius.values, (value) => rem(value)), primaryColor: color.roles.primary.color,
shadows: mapValues(shadows.values, (layers) => primaryShade: color.primaryShade as MantinePrimaryShade,
radius: mapValues(radius.values, value => rem(value)),
respectReducedMotion: true,
shadows: mapValues(shadows.values, layers =>
layers layers
.map( .map(
(layer) => layer =>
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`, `${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
) )
.join(", "), .join(", "),
), ),
breakpoints: mapValues( spacing: mapValues(spacing.values, value => rem(value)),
breakpoints.values, white: color.base.white,
(value) => `${value}${breakpoints.unit}`,
),
}); });
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({
dark: {},
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
variables: Object.fromEntries( variables: Object.fromEntries(
Object.entries(colorRoles).map(([role, reference]) => [ Object.entries(colorRoles).map(([role, reference]) => [
`--wl-color-${role}`, `--wl-color-${role}`,
getThemeColor(reference, resolvedTheme), getThemeColor(reference, resolvedTheme),
]), ]),
), ),
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
dark: {},
}); });
export { colorRoles, cssVariablesResolver, designTokens, theme }; export { colorRoles, cssVariablesResolver, theme };
export { default as designTokens } from "@wealthledger/tokens/tokens.json";
+1 -1
View File
@@ -3,9 +3,9 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
cssFileName: "styles",
entry: { index: "src/index.ts", theme: "src/theme.ts" }, entry: { index: "src/index.ts", theme: "src/theme.ts" },
formats: ["es"], formats: ["es"],
cssFileName: "styles",
}, },
rolldownOptions: { rolldownOptions: {
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/, external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
+22 -18
View File
@@ -1,31 +1,31 @@
const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { const {
chmodSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
writeFileSync,
chmodSync,
rmSync, rmSync,
writeFileSync,
} = require("node:fs"); } = require("node:fs");
const { tmpdir } = require("node:os"); const { tmpdir } = require("node:os");
const { join, resolve } = require("node:path"); const nodePath = require("node:path");
const { test } = require("node:test");
const EXECUTABLE_MODE = 0o755; const EXECUTABLE_MODE = 0o755;
const { spawnSync } = require("node:child_process"); const { spawnSync } = require("node:child_process");
test("immutable publication resumes exact digest checks after post-push failure", () => { test("immutable publication resumes exact digest checks after post-push failure", () => {
const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-")); const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync( const workflow = readFileSync(
resolve(__dirname, "../.gitea/workflows/image.yml"), nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
"utf8", "utf8",
); );
const block = workflow const block = workflow
.split(" - name: Publish immutable release tag\n run: |\n")[1] .split(" - name: Publish immutable release tag\n run: |\n", 2)[1]
.split(" - name:")[0] .split(" - name:", 1)[0]
.replace(/^ {10}/gm, ""); .replaceAll(/^ {10}/gm, "");
const double = `#!${process.execPath} const double = String.raw`#!${process.execPath}
const fs = require('node:fs'); const fs = require('node:fs');
const args = process.argv.slice(2).join(' '); const args = process.argv.slice(2).join(' ');
fs.appendFileSync('calls', args + '\\n'); fs.appendFileSync('calls', args + '\n');
if (args.startsWith('manifest inspect')) { if (args.startsWith('manifest inspect')) {
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); } if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
} else if (args.startsWith('buildx imagetools inspect')) { } else if (args.startsWith('buildx imagetools inspect')) {
@@ -41,8 +41,10 @@ if (args.startsWith('manifest inspect')) {
`; `;
try { try {
for (const name of ["docker", "npm"]) { for (const name of ["docker", "npm"]) {
writeFileSync(join(directory, name), double); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(join(directory, name), EXECUTABLE_MODE); writeFileSync(nodePath.join(directory, name), double);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
} }
const run = (extra = {}) => const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], { spawnSync("/bin/sh", ["-ec", block], {
@@ -50,8 +52,8 @@ if (args.startsWith('manifest inspect')) {
encoding: "utf8", encoding: "utf8",
env: { env: {
...process.env, ...process.env,
PATH: `${directory}:${process.env.PATH}`,
IMAGE: "synthetic/image", IMAGE: "synthetic/image",
PATH: `${directory}:${process.env.PATH}`,
RELEASE_TAG: "v1.2.3", RELEASE_TAG: "v1.2.3",
REVISION: "commit", REVISION: "commit",
...extra, ...extra,
@@ -62,14 +64,16 @@ if (args.startsWith('manifest inspect')) {
assert.equal(retry.status, 0, retry.stdout + retry.stderr); assert.equal(retry.status, 0, retry.stdout + retry.stderr);
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0); assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0); assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
const calls = readFileSync(join(directory, "calls"), "utf8"); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
assert.equal( assert.equal(
calls.split("\n").filter((line) => line.startsWith("buildx build")) calls.split("\n").filter(line => line.startsWith("buildx build"))
.length, .length,
1, 1,
); );
assert.ok(calls.includes("synthetic/image@sha256:exact")); assert.ok(calls.includes("synthetic/image@sha256:exact"));
} finally { }
rmSync(directory, { recursive: true, force: true }); finally {
rmSync(directory, { force: true, recursive: true });
} }
}); });
+14 -11
View File
@@ -1,14 +1,14 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import nodePath from "node:path";
const temporaryDirectory = await mkdtemp( const temporaryDirectory = await mkdtemp(
join(tmpdir(), "wealth-ledger-openapi-"), nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
); );
const generatedPath = join(temporaryDirectory, "schema.d.ts"); const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = resolve("src/shared/api/generated/schema.d.ts"); const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js"); const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0; let exitCode = 0;
try { try {
@@ -18,12 +18,10 @@ try {
{ encoding: "utf8" }, { encoding: "utf8" },
); );
if (generated.status !== 0) { if (generated.status === 0) {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
} else {
const [committed, current] = await Promise.all([ const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"), readFile(committedPath, "utf8"),
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
readFile(generatedPath, "utf8"), readFile(generatedPath, "utf8"),
]); ]);
if (committed !== current) { if (committed !== current) {
@@ -33,8 +31,13 @@ try {
exitCode = 1; exitCode = 1;
} }
} }
} finally { else {
await rm(temporaryDirectory, { recursive: true, force: true }); process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
} }
process.exitCode = exitCode; process.exitCode = exitCode;
+6 -4
View File
@@ -1,14 +1,16 @@
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process"); const { execFileSync } = require("node:child_process");
const { version } = require("../package.json"); const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); // eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim();
assert.equal( assert.equal(
git("status", "--porcelain"), git("status", "--porcelain"),
"", "",
"Commit all changes before preparing a release", "Commit all changes before preparing a release",
); );
const tags = git( const tag_ = git(
"tag", "tag",
"--merged", "--merged",
"HEAD", "HEAD",
@@ -17,9 +19,9 @@ const tags = git(
"--sort=-version:refname", "--sort=-version:refname",
) )
.split("\n") .split("\n")
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); .find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal( assert.equal(
version, version,
tags[0]?.slice(1) ?? "0.0.0", tag_?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.", "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
); );
+117 -6
View File
@@ -1,6 +1,22 @@
import react from "@eslint-react/eslint-plugin";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { ESLint } from "eslint";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { ESLint } from "eslint"; import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => { test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint(); const eslint = new ESLint();
@@ -12,7 +28,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
], ],
[ [
"packages/ui/src/EntryCard/EntryCard.tsx", "packages/ui/src/EntryCard/EntryCard.tsx",
'import { useState } from "react"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }', "import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
"react-hooks/rules-of-hooks", "react-hooks/rules-of-hooks",
], ],
[ [
@@ -25,26 +41,61 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"export default 1;", "export default 1;",
"import-x/no-default-export", "import-x/no-default-export",
], ],
[
"src/shared/api/problem-details.ts",
"new Error(\"forgot to throw\");",
"sonarjs/no-unthrown-error",
],
[
"src/shared/api/problem-details.ts",
"export const pattern = /[aab]/;",
"regexp/no-dupe-characters-character-class",
],
[
"src/shared/api/problem-details.ts",
"export type Status = \"z\" | \"a\";",
"perfectionist/sort-union-types",
],
[
"src/shared/api/problem-details.ts",
"//missing space\nexport const ready = true;",
"@stylistic/spaced-comment",
],
[
"src/shared/api/problem-details.ts",
"export function pattern(input: string) { return new RegExp(input); }",
"security/detect-non-literal-regexp",
],
[
"scripts/check-openapi.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
"jest-dom/prefer-focus",
],
[ [
"src/shared/api/client.test.ts", "src/shared/api/client.test.ts",
'import { it } from "vitest"; it.only("focused", () => {});', "import { it } from \"vitest\"; it.only(\"focused\", () => {});",
"vitest/no-focused-tests", "vitest/no-focused-tests",
], ],
[ [
"packages/ui/src/EntryCard/EntryCard.test.tsx", "packages/ui/src/EntryCard/EntryCard.test.tsx",
'import { screen } from "@testing-library/react"; screen.findByText("ready");', "import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
"testing-library/await-async-queries", "testing-library/await-async-queries",
], ],
[ [
"packages/ui/src/EntryCard/EntryCard.tsx", "packages/ui/src/EntryCard/EntryCard.tsx",
'import { QueryClient } from "@tanstack/react-query"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }', "import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
"@tanstack/query/stable-query-client", "@tanstack/query/stable-query-client",
], ],
]) { ]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, filePath); assert.equal(result.fatalErrorCount, 0, filePath);
assert.ok( assert.ok(
result.messages.some((message) => message.ruleId === rule), result.messages.some(message => message.ruleId === rule),
rule, rule,
); );
} }
@@ -62,3 +113,63 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
assert.ok(story.rules["storybook/default-exports"][0] > 0); assert.ok(story.rules["storybook/default-exports"][0] > 0);
assert.equal(story.rules["react-refresh/only-export-components"], undefined); assert.equal(story.rules["react-refresh/only-export-components"], undefined);
}); });
test("all recommended plugin rules remain enabled in their intended scopes", async () => {
const eslint = new ESLint();
const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
[unicorn.configs.recommended, source],
[sonarjs.configs.recommended, source],
[regexp.configs.recommended, source],
[security.configs.recommended, source],
[perfectionist.configs["recommended-natural"], source],
[stylistic.configs.recommended, component],
[react.configs["recommended-type-checked"], component],
[hooks.configs.flat.recommended, component],
[refresh.configs.vite, component],
[query.configs["flat/recommended"], component],
[vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
]) {
const configs = [preset].flat();
const expected = Object.assign({}, ...configs.map(config => config.rules));
const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
}
}
});
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath },
);
assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output);
const [again] = await eslint.lintText(fixed.output, { filePath });
assert.equal(again.errorCount, 0);
assert.equal(again.output, undefined);
});
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
});
+22 -13
View File
@@ -9,53 +9,62 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path"; import nodePath from "node:path";
import { test } from "node:test"; import { test } from "node:test";
const GITHUB_TOKEN_RANDOM_BYTES = 18; const GITHUB_TOKEN_RANDOM_BYTES = 18;
test("pre-commit scans the index, redacts findings and fails closed", () => { test("pre-commit scans the index, redacts findings and fails closed", () => {
const hook = resolve(".husky/pre-commit"); const hook = nodePath.resolve(".husky/pre-commit");
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-")); const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
const bin = join(cwd, "bin"); const bin = nodePath.join(cwd, "bin");
const marker = join(cwd, "downstream"); const marker = nodePath.join(cwd, "downstream");
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`; const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" }); // eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const run = (path = `${bin}${delimiter}${process.env.PATH}`) => const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" });
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], { spawnSync("/bin/sh", ["-e", hook], {
cwd, cwd,
env: { ...process.env, PATH: path },
encoding: "utf8", encoding: "utf8",
env: { ...process.env, PATH: path },
}); });
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin); mkdirSync(bin);
for (const command of ["npx", "npm"]) { for (const command of ["npx", "npm"]) {
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
mode: 0o755, mode: 0o755,
}); });
} }
git("init", "--quiet"); git("init", "--quiet");
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n"); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
git("add", "sample.txt"); git("add", "sample.txt");
// An unstaged secret must not be mistaken for the index being committed. // An unstaged secret must not be mistaken for the index being committed.
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), `token=${token}\n`);
let result = run(); let result = run();
assert.equal( assert.equal(
result.status, result.status,
0, 0,
"clean index must pass; install Gitleaks first", "clean index must pass; install Gitleaks first",
); );
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(existsSync(marker)); assert.ok(existsSync(marker));
rmSync(marker); rmSync(marker);
git("add", "sample.txt"); git("add", "sample.txt");
result = run(); result = run();
assert.equal(result.status, 1, "staged secret must block commit"); assert.equal(result.status, 1, "staged secret must block commit");
assert.ok(!`${result.stdout}${result.stderr}`.includes(token)); assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker), "formatting/dependency checks must not run"); assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
result = run(bin); result = run(bin);
assert.notEqual(result.status, 0, "missing Gitleaks must block commit"); assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker)); assert.ok(!existsSync(marker));
} finally { }
rmSync(cwd, { recursive: true, force: true }); finally {
rmSync(cwd, { force: true, recursive: true });
} }
}); });
+137 -119
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
@@ -7,96 +9,104 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import type { FC } from "react";
import { MemoryRouter, useLocation } from "react-router"; import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
const accounts = [ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
]; ];
const position: Portfolio["positions"][number] = { const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
figi: "SBER", {
displayName: "Сбербанк", absoluteReturn: null,
type: "STOCK", averagePurchasePrice: null,
quantity: "2", currentValue: { currency: "RUB", value: "600" },
currentValue: { currency: "RUB", value: "600" }, displayName: "Сбербанк",
absoluteReturn: null, figi: "SBER",
averagePurchasePrice: null, isin: null,
isin: null, logoName: null,
ticker: "SBER", quantity: "2",
logoName: null, ticker: "SBER",
}; type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
const portfolio: Portfolio = { const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-15T12:00:00Z",
isStale: false,
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [], cashBalances: [],
positions: accounts.map((account) => ({ ...position, account })), isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })),
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation = { const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id: "same", {
occurredAt: "2026-08-15T12:00:00Z", accruedInterest: null,
type: "OPERATION_TYPE_COUPON", category: "income",
category: "income", commission: null,
description: "Выплата купона", description: "Выплата купона",
figi: null, figi: null,
instrumentUid: null, id: "same",
ticker: "BOND", instrumentType: "bond",
instrumentType: "bond", instrumentUid: null,
logoName: null, logoName: null,
payment: { currency: "RUB", value: "1" }, occurredAt: "2026-08-15T12:00:00Z",
quantity: null, payment: { currency: "RUB", value: "1" },
price: null, price: null,
accruedInterest: null, quantity: null,
commission: null, ticker: "BOND",
}; type: "OPERATION_TYPE_COUPON",
const items = accounts.map((account) => ({ ...receipt, account })); }
const analytics: IncomeAnalytics = { /* eslint-enable unicorn/no-null */);
accountId: "all", const items = accounts.map(account => ({ ...receipt, account }));
period: { from: "2026-08-01", to: "2026-08-31" }, const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
calculatedAt: "2026-09-01T12:00:00Z", {
totalsByCurrency: [ accountId: "all",
{ bucketGranularity: "day",
currency: "RUB", buckets: [],
coupon: { currency: "RUB", value: "2" }, calculatedAt: "2026-09-01T12:00:00Z",
dividend: { currency: "RUB", value: "0" }, entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })),
total: { currency: "RUB", value: "2" }, instruments: [
{
coupon: { currency: "RUB", value: "2" },
displayName: "Облигация",
dividend: { currency: "RUB", value: "0" },
figi: null,
instrumentType: "bond",
instrumentUid: null,
key: "bond:RUB",
receiptCount: 2,
ticker: "BOND",
total: { currency: "RUB", value: "2" },
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
}, },
], totalsByCurrency: [
previous: { {
period: { from: "2026-07-01", to: "2026-07-31" }, coupon: { currency: "RUB", value: "2" },
totalsByCurrency: [], currency: "RUB",
}, dividend: { currency: "RUB", value: "0" },
yearComparison: null, total: { currency: "RUB", value: "2" },
bucketGranularity: "day", },
buckets: [], ],
instruments: [ yearComparison: null,
{ }
key: "bond:RUB", /* eslint-enable unicorn/no-null */);
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "Облигация",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
receiptCount: 2,
},
],
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
};
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
const location = useLocation(); const location = useLocation();
return ( return (
@@ -128,18 +138,20 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test", accessToken: "test",
user: { id: "user", email: "user@example.com" }, user: { email: "user@example.com", id: "user" },
}), }),
), ),
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
accounts.map((account) => ({ accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
...account, {
type: "BROKERAGE", ...account,
openedOn: null, openedOn: null,
portfolioSummary: null, portfolioSummary: null,
})), type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */))),
), ),
), ),
), ),
@@ -150,13 +162,15 @@ beforeEach(() => {
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) => ({ params }) =>
HttpResponse.json( HttpResponse.json(
resource({ resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
accountId: params.accountId, {
items, accountId: params.accountId,
hasNext: false, hasNext: false,
nextCursor: null, items,
observedAt: receipt.occurredAt, nextCursor: null,
}), observedAt: receipt.occurredAt,
}
/* eslint-enable unicorn/no-null */)),
), ),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
@@ -167,20 +181,22 @@ beforeEach(() => {
resource({ resource({
accountId: "all", accountId: "all",
calculatedAt: receipt.occurredAt, calculatedAt: receipt.occurredAt,
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
account,
date: "2026-09-15",
dateKind: "event",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
events: accounts.map((account) => ({
account,
type: "coupon",
date: "2026-09-15",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "BOND",
displayName: "Облигация",
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
})),
}), }),
), ),
), ),
@@ -219,7 +235,7 @@ describe("All accounts routes", () => {
); );
for (const suffix of ["operations", "income", "events"]) for (const suffix of ["operations", "income", "events"])
expect( expect(
document.querySelector(`a[href="/accounts/all/${suffix}"]`), screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -228,24 +244,26 @@ describe("All accounts routes", () => {
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ request, params }) => { ({ params, request }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url); requests.push(url);
return HttpResponse.json( return HttpResponse.json(
resource({ resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
accountId: params.accountId, {
items: [ accountId: params.accountId,
{ hasNext: !url.searchParams.has("cursor"),
...receipt, items: [
description: url.searchParams.has("cursor") {
? "Вторая страница" ...receipt,
: "Первая страница", description: url.searchParams.has("cursor")
}, ? "Вторая страница"
], : "Первая страница",
hasNext: !url.searchParams.has("cursor"), },
nextCursor: url.searchParams.has("cursor") ? null : "next", ],
observedAt: receipt.occurredAt, nextCursor: url.searchParams.has("cursor") ? null : "next",
}), observedAt: receipt.occurredAt,
}
/* eslint-enable unicorn/no-null */)),
); );
}, },
), ),
@@ -260,10 +278,10 @@ describe("All accounts routes", () => {
fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() => await waitFor(() =>
expect( expect(
requests.some((url) => url.pathname.includes("/b/operations")), requests.some(url => url.pathname.includes("/b/operations")),
).toBe(true), ).toBe(true),
); );
const selected = requests.find((url) => const selected = requests.find(url =>
url.pathname.includes("/b/operations"), url.pathname.includes("/b/operations"),
); );
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z"); expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
@@ -286,8 +304,8 @@ describe("All accounts routes", () => {
: HttpResponse.json( : HttpResponse.json(
resource({ resource({
accountId: "all", accountId: "all",
items,
hasNext: true, hasNext: true,
items,
nextCursor: "expired", nextCursor: "expired",
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
}), }),
@@ -305,7 +323,7 @@ describe("All accounts routes", () => {
}); });
it("retains the complete portfolio during a failed refresh", async () => { it("retains the complete portfolio during a failed refresh", async () => {
const client = renderPage("/accounts/all"); const view = renderPage("/accounts/all");
await screen.findAllByText("Сбербанк"); await screen.findAllByText("Сбербанк");
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/all/portfolio", () => http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
@@ -313,7 +331,7 @@ describe("All accounts routes", () => {
), ),
); );
await act(() => await act(() =>
client.invalidateQueries({ view.invalidateQueries({
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"), predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
}), }),
); );
+33 -29
View File
@@ -2,32 +2,34 @@ import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => { function renderRoutes(path: string) {
function renderRoutes(path: string) { return render(
return render( <QueryClientProvider client={createTestQueryClient()}>
<QueryClientProvider client={createTestQueryClient()}> <MemoryRouter initialEntries={[path]}>
<MemoryRouter initialEntries={[path]}> <MantineProvider
<MantineProvider cssVariablesResolver={cssVariablesResolver}
cssVariablesResolver={cssVariablesResolver} theme={theme}
theme={theme} >
> <AuthProvider>
<AuthProvider> <AppRoutes />
<AppRoutes /> </AuthProvider>
</AuthProvider> </MantineProvider>
</MantineProvider> </MemoryRouter>
</MemoryRouter> </QueryClientProvider>,
</QueryClientProvider>, );
); }
}
describe("AppRoutes", () => {
it.each([ it.each([
["/", "Вход"], ["/", "Вход"],
["/login", "Вход"], ["/login", "Вход"],
@@ -62,7 +64,7 @@ describe("AppRoutes", () => {
credentials = await request.json(); credentials = await request.json();
return HttpResponse.json({ return HttpResponse.json({
accessToken: "login-token", accessToken: "login-token",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}); });
}), }),
); );
@@ -96,19 +98,21 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/login", () => http.post("*/api/v1/auth/login", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test", accessToken: "test",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
http.get("*/api/v1/bank-cards/summary", () => http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json({ HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
month: "2026-08", {
cardId: null, cardId: null,
expenses: { currency: "RUB", value: "0" }, categories: [],
refunds: { currency: "RUB", value: "0" }, expenses: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" }, month: "2026-08",
categories: [], net: { currency: "RUB", value: "0" },
}), refunds: { currency: "RUB", value: "0" },
}
/* eslint-enable unicorn/no-null */)),
), ),
); );
renderRoutes("/cards?month=2026-08"); renderRoutes("/cards?month=2026-08");
@@ -132,7 +136,7 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test-token", accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
); );
+62 -60
View File
@@ -1,6 +1,8 @@
import type { FC } from "react"; import type { FC } from "react";
import { lazy } from "react"; import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router"; import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback"; import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards"; import { Protected, Public } from "../RouteGuards";
@@ -71,172 +73,172 @@ const ProtectedLayout = lazy(async () => {
const AppRoutes: FC = () => ( const AppRoutes: FC = () => (
<Routes> <Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} /> <Route element={<Navigate replace to="/accounts" />} path="/" />
<Route <Route
path="/login" element={(
element={
<Public> <Public>
<LazyPage variant="auth"> <LazyPage variant="auth">
<LoginPage /> <LoginPage />
</LazyPage> </LazyPage>
</Public> </Public>
} )}
path="/login"
/> />
<Route <Route
path="/register" element={(
element={
<Public> <Public>
<LazyPage variant="auth"> <LazyPage variant="auth">
<RegisterPage /> <RegisterPage />
</LazyPage> </LazyPage>
</Public> </Public>
} )}
path="/register"
/> />
<Route <Route
element={ element={(
<Protected> <Protected>
<LazyPage variant="shell"> <LazyPage variant="shell">
<ProtectedLayout /> <ProtectedLayout />
</LazyPage> </LazyPage>
</Protected> </Protected>
} )}
> >
<Route <Route
path="/cards/:cardId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<CardPage /> <CardPage />
</LazyPage> </LazyPage>
} )}
path="/cards/:cardId"
/> />
<Route <Route
path="/cards" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<CardsPage /> <CardsPage />
</LazyPage> </LazyPage>
} )}
path="/cards"
/> />
<Route <Route
path="/bonds" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<BondsPage /> <BondsPage />
</LazyPage> </LazyPage>
} )}
path="/bonds"
/> />
<Route <Route
path="/bonds/:bondId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<BondPage /> <BondPage />
</LazyPage> </LazyPage>
} )}
path="/bonds/:bondId"
/> />
<Route <Route
path="/stocks" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<StocksPage /> <StocksPage />
</LazyPage> </LazyPage>
} )}
path="/stocks"
/> />
<Route <Route
path="/stocks/:stockId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<StockPage /> <StockPage />
</LazyPage> </LazyPage>
} )}
path="/stocks/:stockId"
/> />
<Route <Route
path="/accounts" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountsPage /> <AccountsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts"
/> />
<Route <Route
path="/accounts/all" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountPage accountId="all" /> <AccountPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all"
/> />
<Route <Route
path="/accounts/all/operations" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountOperationsPage accountId="all" /> <AccountOperationsPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/operations"
/> />
<Route <Route
path="/accounts/all/income" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountIncomePage accountId="all" /> <AccountIncomePage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/income"
/> />
<Route <Route
path="/accounts/all/events" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountEventsPage accountId="all" /> <AccountEventsPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/events"
/> />
<Route <Route
path="/accounts/:accountId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountPage /> <AccountPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId"
/> />
<Route <Route
path="/accounts/:accountId/events" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountEventsPage /> <AccountEventsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/events"
/> />
<Route <Route
path="/accounts/:accountId/income" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountIncomePage /> <AccountIncomePage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/income"
/> />
<Route <Route
path="/accounts/:accountId/operations" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountOperationsPage /> <AccountOperationsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/operations"
/> />
<Route <Route
path="/settings" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<SettingsPage /> <SettingsPage />
</LazyPage> </LazyPage>
} )}
path="/settings"
/> />
</Route> </Route>
<Route <Route
path="*" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<NotFoundPage /> <NotFoundPage />
</LazyPage> </LazyPage>
} )}
path="*"
/> />
</Routes> </Routes>
); );
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
act, act,
@@ -6,24 +8,25 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import type { FC } from "react";
import { MemoryRouter, useNavigate } from "react-router"; import { MemoryRouter, useNavigate } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { ProtectedDocumentTitle } from "./DocumentTitle"; import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => { const Navigation: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<button type="button" onClick={() => navigate("/accounts/all/income")}> <button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета Все счета
</button> </button>
<button type="button" onClick={() => navigate(-1)}> <button onClick={() => navigate(-1)} type="button">
Назад Назад
</button> </button>
</> </>
@@ -48,7 +51,7 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test-token", accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
); );
@@ -56,10 +59,7 @@ beforeEach(() => {
describe("document titles", () => { describe("document titles", () => {
it("updates a loading account name and follows account scope and history", async () => { it("updates a loading account name and follows account scope and history", async () => {
let resolveAccounts!: () => void; const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers<void>();
const accountsReady = new Promise<void>((resolve) => {
resolveAccounts = resolve;
});
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", async () => { http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady; await accountsReady;
@@ -68,7 +68,7 @@ describe("document titles", () => {
); );
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
expect(document.title).toBe("Пассивный доход — Счет — WealthLedger"); expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
await act(async () => resolveAccounts()); await act(() => Promise.resolve(resolveAccounts()));
await waitFor(() => await waitFor(() =>
expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"), expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
); );
@@ -98,15 +98,15 @@ describe("document titles", () => {
); );
it("keeps a useful title when the instrument is unavailable", async () => { it("keeps a useful title when the instrument is unavailable", async () => {
let requested = false; let isRequested = false;
server.use( server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => { http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
requested = true; isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}), }),
); );
renderTitle("/stocks/missing"); renderTitle("/stocks/missing");
await waitFor(() => expect(requested).toBe(true)); await waitFor(() => expect(isRequested).toBe(true));
expect(document.title).toBe("Акция — WealthLedger"); expect(document.title).toBe("Акция — WealthLedger");
}); });
}); });
+30 -30
View File
@@ -1,7 +1,9 @@
import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -9,10 +11,9 @@ import { useStock } from "@/entities/stock";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props) => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`); useDocumentTitle(`${props.title} — ${t("appName")}`);
return null;
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -23,19 +24,29 @@ type AccountDocumentTitleProps = {
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => { const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBrokerageAccounts(); const { data } = useBrokerageAccounts();
const accountName = const accountName
props.accountId === "all" = props.accountId === "all"
? t("accounts.all") ? t("accounts.all")
: (data?.find((account) => account.id === props.accountId)?.name ?? : (data?.find(account => account.id === props.accountId)?.name
t("account.title")); ?? t("account.title"));
const section = let section = t("portfolio.title");
props.section === "income" switch (props.section ?? "") {
? t("income.title") case "income": {
: props.section === "operations" section = t("income.title");
? t("ledger.title") break;
: props.section === "events" }
? t("events.title") case "operations": {
: t("portfolio.title"); section = t("ledger.title");
break;
}
case "events": {
section = t("events.title");
break;
}
default: {
break;
}
}
return <DocumentTitle title={`${section} — ${accountName}`} />; return <DocumentTitle title={`${section} — ${accountName}`} />;
}; };
@@ -80,21 +91,10 @@ const ProtectedDocumentTitle: FC = () => {
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />; if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />; return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
return ( if (cards) return <DocumentTitle title={t("navigation.cards")} />;
<DocumentTitle if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
title={t( if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
cards return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
? "navigation.cards"
: bonds
? "navigation.bonds"
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
)}
/>
);
}; };
export { ProtectedDocumentTitle }; export { ProtectedDocumentTitle };
+2
View File
@@ -1,5 +1,7 @@
import type { FC } from "react"; import type { FC } from "react";
import { AppBreadcrumbs, AppShell } from "@/app/ui"; import { AppBreadcrumbs, AppShell } from "@/app/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle"; import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle";
const ProtectedLayout: FC = () => { const ProtectedLayout: FC = () => {
@@ -3,11 +3,14 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { type FC, lazy, Suspense } from "react"; import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { RouteErrorBoundary } from "./RouteErrorBoundary";
let authenticated = false; const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({ vi.mock("@/features/auth", () => ({
useAuth: () => ({ user: authenticated ? {} : null }), useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ user: session.isAuthenticated ? {} : null }
/* eslint-enable unicorn/no-null */)),
})); }));
const ThrowingRoute: FC = () => { const ThrowingRoute: FC = () => {
@@ -17,11 +20,13 @@ const ThrowingRoute: FC = () => {
const HealthyRoute: FC = () => { const HealthyRoute: FC = () => {
return ( return (
<div> <div>
{useLocation().pathname === "/healthy" ? ( {useLocation().pathname === "/healthy"
"healthy route" ? (
) : ( "healthy route"
<ThrowingRoute /> )
)} : (
<ThrowingRoute />
)}
</div> </div>
); );
}; };
@@ -31,7 +36,7 @@ const RouteHarness: FC = () => {
return ( return (
<> <>
<button type="button" onClick={() => navigate("/healthy")}> <button onClick={() => navigate("/healthy")} type="button">
go healthy go healthy
</button> </button>
<RouteErrorBoundary> <RouteErrorBoundary>
@@ -55,10 +60,10 @@ describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])( it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself", "recovers a transient failure through the fallback on %s itself",
async (path) => { async (path) => {
authenticated = path === "/accounts"; session.isAuthenticated = path === "/accounts";
let failing = true; let isFailing = true;
const RecoverableRoute: FC = () => { const RecoverableRoute: FC = () => {
if (failing) throw new Error("temporary failure"); if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>; return <div>recovered route</div>;
}; };
render( render(
@@ -71,14 +76,14 @@ describe("RouteErrorBoundary", () => {
</MantineProvider>, </MantineProvider>,
); );
const action = screen.getByRole("link", { name: "Продолжить" }); const action = screen.getByRole("link", { name: "Продолжить" });
failing = false; isFailing = false;
fireEvent.click(action); fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument(); expect(await screen.findByText("recovered route")).toBeInTheDocument();
}, },
); );
it("keeps a persistent failure recoverable without an automatic reset loop", () => { it("keeps a persistent failure recoverable without an automatic reset loop", () => {
authenticated = true; session.isAuthenticated = true;
renderBoundary("/accounts"); renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" })); fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect( expect(
@@ -91,7 +96,7 @@ describe("RouteErrorBoundary", () => {
}); });
it("offers document navigation when React.lazy caches a rejected chunk", async () => { it("offers document navigation when React.lazy caches a rejected chunk", async () => {
authenticated = false; session.isAuthenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed"))); const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load); const Chunk = lazy(load);
render( render(
@@ -110,18 +115,18 @@ describe("RouteErrorBoundary", () => {
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" }); const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login"); expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it. // Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let intercepted = true; let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => { const preventNavigation = (event: MouseEvent) => {
intercepted = event.defaultPrevented; isIntercepted = event.defaultPrevented;
event.preventDefault(); event.preventDefault();
}; };
document.addEventListener("click", preventNavigation, { once: true }); document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload); fireEvent.click(reload);
expect(intercepted).toBe(false); expect(isIntercepted).toBe(false);
}); });
it("offers login after a public route error without error details", () => { it("offers login after a public route error without error details", () => {
authenticated = false; session.isAuthenticated = false;
renderBoundary("/login"); renderBoundary("/login");
expect(document.title).toBe("Не удалось открыть страницу — WealthLedger"); expect(document.title).toBe("Не удалось открыть страницу — WealthLedger");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
@@ -132,9 +137,9 @@ describe("RouteErrorBoundary", () => {
}); });
it("offers accounts after a protected route error and resets on navigation", async () => { it("offers accounts after a protected route error and resets on navigation", async () => {
authenticated = true; session.isAuthenticated = true;
renderBoundary("/accounts"); renderBoundary("/accounts");
screen.getByRole("button", { name: "go healthy" }).click(); fireEvent.click(screen.getByRole("button", { name: "go healthy" }));
expect(await screen.findByText("healthy route")).toBeInTheDocument(); expect(await screen.findByText("healthy route")).toBeInTheDocument();
}); });
}); });
@@ -1,14 +1,16 @@
import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks"; import { useDocumentTitle } from "@mantine/hooks";
import type { FC, PropsWithChildren } from "react";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
routeKey: string;
authenticated: boolean; authenticated: boolean;
routeKey: string;
}>; }>;
type RouteErrorFallbackProps = { type RouteErrorFallbackProps = {
@@ -18,12 +20,12 @@ type RouteErrorFallbackProps = {
type State = { hasError: boolean }; type State = { hasError: boolean };
class Boundary extends Component<BoundaryProps, State> { class Boundary extends Component<BoundaryProps, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State { static getDerivedStateFromError(): State {
return { hasError: true }; return { hasError: true };
} }
state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) { componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false }); this.setState({ hasError: false });
@@ -31,10 +33,7 @@ class Boundary extends Component<BoundaryProps, State> {
} }
render() { render() {
if (this.state.hasError) { return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children;
return <RouteErrorFallback authenticated={this.props.authenticated} />;
}
return this.props.children;
} }
} }
@@ -60,7 +59,7 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => { const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { pathname, search, key } = useLocation(); const { key, pathname, search } = useLocation();
const { user } = useAuth(); const { user } = useAuth();
return ( return (
+12 -8
View File
@@ -1,11 +1,13 @@
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RouteTransition } from "./RouteTransition"; import { RouteTransition } from "./RouteTransition";
type RouteSkeletonVariant = "auth" | "content" | "shell";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>; type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
type RouteSkeletonVariant = "auth" | "content" | "shell";
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props; const { variant } = props;
@@ -13,7 +15,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
if (variant === "auth") if (variant === "auth")
return ( return (
<Container size={420} py="xl"> <Container py="xl" size={420}>
<Stack <Stack
aria-busy="true" aria-busy="true"
aria-label={t("states.loadingTitle")} aria-label={t("states.loadingTitle")}
@@ -61,11 +63,13 @@ const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props; const { children, variant } = props;
return ( return (
<Suspense fallback={<RouteSkeleton variant={variant} />}> <Suspense fallback={<RouteSkeleton variant={variant} />}>
{variant === "shell" ? ( {variant === "shell"
children ? (
) : ( children
<RouteTransition>{children}</RouteTransition> )
)} : (
<RouteTransition>{children}</RouteTransition>
)}
</Suspense> </Suspense>
); );
}; };
+24 -18
View File
@@ -1,37 +1,43 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { RouteSkeleton } from "./RouteFallback"; import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { user, ready } = useAuth(); const { ready, user } = useAuth();
const location = useLocation(); const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
return user ? ( return user
children ? (
) : ( <>{children}</>
<Navigate )
to="/login" : (
state={{ returnTo: `${location.pathname}${location.search}` }} <Navigate
replace replace
/> state={{ returnTo: `${location.pathname}${location.search}` }}
); to="/login"
/>
);
}; };
const Public: FC<PropsWithChildren> = (props) => { const Public: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { user, ready } = useAuth(); const { ready, user } = useAuth();
const location = useLocation(); const location = useLocation();
const requested = (location.state as { returnTo?: string } | null)?.returnTo;
const returnTo = if (!ready) return <RouteSkeleton variant="shell" />;
typeof requested === "string" && const requested = (location.state as null | { returnTo?: string })?.returnTo;
requested.startsWith("/") && const returnTo
!requested.startsWith("//") = typeof requested === "string"
&& requested.startsWith("/")
&& !requested.startsWith("//")
? requested ? requested
: "/accounts"; : "/accounts";
if (!ready) return <RouteSkeleton variant="shell" />; return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
return user ? <Navigate to={returnTo} replace /> : children;
}; };
export { Protected, Public }; export { Protected, Public };
+4 -3
View File
@@ -1,6 +1,7 @@
import type { FC, PropsWithChildren } from "react";
import { domAnimation, LazyMotion, useReducedMotion } from "motion/react"; import { domAnimation, LazyMotion, useReducedMotion } from "motion/react";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import type { FC, PropsWithChildren } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
const TRANSITION_DURATION_SECONDS = 0.2; const TRANSITION_DURATION_SECONDS = 0.2;
@@ -13,9 +14,9 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
return ( return (
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>
<m.div <m.div
key={pathname}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
initial={!reduceMotion && { opacity: 0 }}
key={pathname}
transition={{ transition={{
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS, duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut", ease: "easeOut",
+4 -2
View File
@@ -1,9 +1,11 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import type { FC } from "react";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
@@ -17,8 +19,8 @@ const App: FC = () => {
return ( return (
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver} cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light" forceColorScheme="light"
theme={theme}
> >
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -1,6 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = { type AccountBreadcrumbsProps = {
@@ -12,11 +15,11 @@ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props; const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const accountName = const accountName
accountId === "all" = accountId === "all"
? t("accounts.all") ? t("accounts.all")
: (accounts?.find((account) => account.id === accountId)?.name ?? : (accounts?.find(account => account.id === accountId)?.name
t("breadcrumbs.accountFallback")); ?? t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`; const accountPath = `/accounts/${accountId}`;
const items = [ const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" }, { label: t("breadcrumbs.accounts"), to: "/accounts" },
@@ -1,7 +1,9 @@
import { Select } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { Select } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string }; type AccountScopeSwitchProps = { accountId: string; section?: string };
@@ -13,31 +15,31 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation(); const { search } = useLocation();
const data = [ const data = [
{ value: "all", label: t("accounts.all") }, { label: t("accounts.all"), value: "all" },
...(accounts ?? []).map((account) => ({ ...(accounts ?? []).map(account => ({
value: account.id,
label: account.name, label: account.name,
value: account.id,
})), })),
]; ];
if (!data.some((item) => item.value === accountId)) if (data.every(item => item.value !== accountId))
data.push({ value: accountId, label: t("breadcrumbs.accountFallback") }); data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
return ( return (
<Select <Select
label={t("accounts.scope")}
value={accountId}
data={data}
allowDeselect={false} allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360} maw={360}
w="100%"
onChange={(value) => { onChange={(value) => {
if (!value || value === accountId) return; if (!value || value === accountId) return;
const filters = new URLSearchParams(search); const filters = new URLSearchParams(search);
filters.delete("cursor"); filters.delete("cursor");
void navigate({ void navigate({
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`, pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
search: filters.toString(), search: filters.toString(),
}); });
}} }}
value={accountId}
w="100%"
/> />
); );
}; };
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs"; import { AppBreadcrumbs } from "./AppBreadcrumbs";
const { useBrokerageAccounts } = vi.hoisted(() => ({ const { useBrokerageAccounts } = vi.hoisted(() => ({
+12 -12
View File
@@ -1,7 +1,9 @@
import { Stack } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BondBreadcrumbs } from "./BondBreadcrumbs";
@@ -12,19 +14,19 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const operations = useMatch({ const operations = useMatch({
path: "/accounts/:accountId/operations",
end: true, end: true,
path: "/accounts/:accountId/operations",
}); });
const events = useMatch({ path: "/accounts/:accountId/events", end: true }); const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ path: "/accounts/:accountId/income", end: true }); const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ path: "/accounts/:accountId", end: true }); const account = useMatch({ end: true, path: "/accounts/:accountId" });
const card = useMatch("/cards/:cardId"); const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards"); const cards = useMatch("/cards");
const accounts = useMatch({ path: "/accounts", end: true }); const accounts = useMatch({ end: true, path: "/accounts" });
const settings = useMatch({ path: "/settings", end: true }); const settings = useMatch({ end: true, path: "/settings" });
const stock = useMatch({ path: "/stocks/:stockId", end: true }); const stock = useMatch({ end: true, path: "/stocks/:stockId" });
const bond = useMatch({ path: "/bonds/:bondId", end: true }); const bond = useMatch({ end: true, path: "/bonds/:bondId" });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />; if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
@@ -78,9 +80,7 @@ const AppBreadcrumbs: FC = () => {
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings) return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
return null;
}; };
export { AppBreadcrumbs }; export { AppBreadcrumbs };
@@ -1,6 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string }; type BondBreadcrumbsProps = { identifier: string };
+16 -12
View File
@@ -1,11 +1,13 @@
import { Breadcrumbs } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { Breadcrumbs } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css"; import classes from "./AppBreadcrumbs.module.css";
type Crumb = { label: string; to?: string };
type BreadcrumbTrailProps = { items: Crumb[] }; type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => { const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props; const { items } = props;
@@ -18,16 +20,18 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
data-depth={items.length} data-depth={items.length}
separator={<span aria-hidden="true">→</span>} separator={<span aria-hidden="true">→</span>}
> >
{items.map((item) => {items.map(item =>
item.to ? ( item.to
<Link className={classes.link} key={item.to} to={item.to}> ? (
{item.label} <Link className={classes.link} key={item.to} to={item.to}>
</Link> {item.label}
) : ( </Link>
<span aria-current="page" key={item.label}> )
{item.label} : (
</span> <span aria-current="page" key={item.label}>
), {item.label}
</span>
),
)} )}
</Breadcrumbs> </Breadcrumbs>
); );
@@ -1,6 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string }; type CardBreadcrumbsProps = { identifier: string };
@@ -1,6 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock"; import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string }; type StockBreadcrumbsProps = { identifier: string };
+40 -37
View File
@@ -1,8 +1,11 @@
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router"; import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "@/shared/lib"; import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css"; import classes from "./AppShell.module.css";
type AppShellProps = { type AppShellProps = {
@@ -15,9 +18,6 @@ const AppShell: FC<AppShellProps> = (props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const items = [ const items = [
{ {
to: "/accounts",
label: t("navigation.accounts"),
iconClassName: classes.portfolioIcon,
icon: ( icon: (
<> <>
<path d="M3 10v9h18v-9" /> <path d="M3 10v9h18v-9" />
@@ -32,11 +32,11 @@ const AppShell: FC<AppShellProps> = (props) => {
/> />
</> </>
), ),
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
}, },
{ {
to: "/cards",
label: t("navigation.cards"),
iconClassName: classes.cardsIcon,
icon: ( icon: (
<> <>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" /> <path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
@@ -47,42 +47,42 @@ const AppShell: FC<AppShellProps> = (props) => {
</g> </g>
</> </>
), ),
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
}, },
{ {
to: "/stocks",
label: t("navigation.stocks"),
iconClassName: classes.stocksIcon,
icon: ( icon: (
<> <>
<path d="M3 3v18h18" /> <path d="M3 3v18h18" />
<path <path
className={classes.drawStroke} className={classes.drawStroke}
pathLength={1}
d="M7 14l5-5 4 3 5-7" d="M7 14l5-5 4 3 5-7"
pathLength={1}
/> />
<path className={classes.stockArrow} d="M16 5h5v5" /> <path className={classes.stockArrow} d="M16 5h5v5" />
</> </>
), ),
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
}, },
{ {
to: "/bonds",
label: t("navigation.bonds"),
iconClassName: classes.bondsIcon,
icon: ( icon: (
<> <>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" /> <path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}> <g className={classes.couponLines}>
<path pathLength={1} d="M9 7h6" /> <path d="M9 7h6" pathLength={1} />
<path pathLength={1} d="M9 11h6" /> <path d="M9 11h6" pathLength={1} />
<path pathLength={1} d="M9 15h3" /> <path d="M9 15h3" pathLength={1} />
</g> </g>
</> </>
), ),
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
}, },
{ {
to: "/settings",
label: t("navigation.settings"),
iconClassName: classes.settingsIcon,
icon: ( icon: (
<> <>
<path d="M4 7h16M4 17h16" /> <path d="M4 7h16M4 17h16" />
@@ -90,48 +90,51 @@ const AppShell: FC<AppShellProps> = (props) => {
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" /> <path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</> </>
), ),
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
}, },
]; ];
return ( return (
<MantineAppShell <MantineAppShell
header={{ height: 60 }} header={{ height: 60 }}
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }} navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md" padding="md"
> >
<MantineAppShell.Header className={classes.header}> <MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text> <Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header> </MantineAppShell.Header>
<MantineAppShell.Navbar p="sm"> <MantineAppShell.Navbar p="sm">
{items.map((item) => ( {items.map(item => (
<NavLink <NavLink
component={RouterNavLink} active={pathname.startsWith(item.to)}
className={classes.navLink} className={classes.navLink}
component={RouterNavLink}
key={item.to} key={item.to}
to={item.to}
label={item.label} label={item.label}
leftSection={ leftSection={(
<span <span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`} className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
> >
<svg <svg
ref={bindIconAnimation} aria-hidden="true"
width={20}
height={20}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={20}
ref={bindIconAnimation}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
width={20}
> >
{item.icon} {item.icon}
</svg> </svg>
</span> </span>
} )}
active={pathname.startsWith(item.to)} to={item.to}
/> />
))} ))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
@@ -141,11 +144,11 @@ const AppShell: FC<AppShellProps> = (props) => {
<Outlet /> <Outlet />
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav className={classes.bottomNav} aria-label={t("appName")}> <nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map((item) => ( {items.map(item => (
<RouterNavLink <RouterNavLink
className={ className={
pathname.startsWith(item.to) ? classes.active : undefined (pathname.startsWith(item.to)) ? (classes.active) : undefined
} }
key={item.to} key={item.to}
to={item.to} to={item.to}
+71 -69
View File
@@ -1,9 +1,11 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api"; import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBankMutations } from "./mutations"; import { useBankMutations } from "./mutations";
import { import {
type BankCard, type BankCard,
@@ -17,32 +19,34 @@ import {
useBankSummary, useBankSummary,
} from "./queries"; } from "./queries";
const card: BankCard = { const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id: "a", {
name: "Daily", archivedAt: null,
bankName: null, balance: { currency: "RUB", value: "100" },
lastFour: null, bankName: null,
paymentSystem: null, id: "a",
openingBalance: { currency: "RUB", value: "100" }, lastFour: null,
openingDate: "2026-09-01", name: "Daily",
balance: { currency: "RUB", value: "100" }, openingBalance: { currency: "RUB", value: "100" },
archivedAt: null, openingDate: "2026-09-01",
version: 1, paymentSystem: null,
}; version: 1,
}
/* eslint-enable unicorn/no-null */);
const transfer: CreateBankOperation = { const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a",
date: "2026-09-12",
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612", id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
kind: "transfer", kind: "transfer",
cardId: "a",
targetCardId: "b", targetCardId: "b",
amount: { currency: "RUB", value: "90071992547409.93" },
date: "2026-09-12",
}; };
function authenticate() { function authenticate() {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
} }
@@ -59,30 +63,28 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
if (url.pathname.endsWith("/categories")) if (url.pathname.endsWith("/categories"))
return HttpResponse.json([ return HttpResponse.json([
{ id: "food", name: categoryName, isDefault: true, version: 1 }, { id: "food", isDefault: true, name: categoryName, version: 1 },
]); ]);
if (url.pathname.endsWith("/summary")) if (url.pathname.endsWith("/summary"))
return HttpResponse.json({ return HttpResponse.json({
month: url.searchParams.get("month"),
cardId: url.searchParams.get("cardId"), cardId: url.searchParams.get("cardId"),
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [], categories: [],
expenses: { currency: "RUB", value: "0" },
month: url.searchParams.get("month"),
net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
}); });
if ( if (
url.pathname.endsWith("/operations") || url.pathname.endsWith("/operations")
url.pathname.endsWith("/expenses") || url.pathname.endsWith("/expenses")
) )
return HttpResponse.json({ return HttpResponse.json({
hasNext: false,
items: [], items: [],
page: 1, page: 1,
total: 0, total: 0,
hasNext: false,
}); });
if (url.pathname.includes("/operations/")) return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
return HttpResponse.json({ ...transfer, version: 1 });
return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}), }),
]; ];
} }
@@ -93,15 +95,16 @@ function useReadModels() {
const b = useBankCard("b"); const b = useBankCard("b");
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20,
month: "2026-09", month: "2026-09",
page: 2, page: 2,
limit: 20,
}); });
const expenses = useBankExpenses("a", { page: 1 }); const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09"); const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b"); const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
mutations: useBankMutations(),
reads: [ reads: [
cards, cards,
a, a,
@@ -113,7 +116,6 @@ function useReadModels() {
august, august,
original, original,
], ],
mutations: useBankMutations(),
}; };
} }
@@ -122,7 +124,7 @@ describe("banking API", () => {
it("loads every page for all mode and fails without exposing partial results", async () => { it("loads every page for all mode and fails without exposing partial results", async () => {
const pages: number[] = []; const pages: number[] = [];
let fail = false; let isFail = false;
server.use( server.use(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -130,33 +132,33 @@ describe("banking API", () => {
pages.push(page); pages.push(page);
expect(url.searchParams.get("limit")).toBe("100"); expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("month")).toBe("2026-09");
if (fail && page === 2) if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 }); return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({ return HttpResponse.json({
items: [{ ...transfer, id: String(page) }],
total: 2,
page,
hasNext: page === 1, hasNext: page === 1,
items: [{ ...transfer, id: String(page) }],
page,
total: 2,
}); });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }), () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]); expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map((item) => item.id)).toEqual([ expect(result.current.data?.items.map(item => item.id)).toEqual([
"1", "1",
"2", "2",
]); ]);
fail = true; isFail = true;
const failed = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }), () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(failed.result.current.isError).toBe(true)); await waitFor(() => expect(failedResult.current.isError).toBe(true));
expect(failed.result.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
@@ -166,29 +168,29 @@ describe("banking API", () => {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await waitFor(() => await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
expect([...counts.keys()]).toContain( expect(counts.keys()).toContain(
"/api/v1/bank-cards?includeArchived=true", "/api/v1/bank-cards?includeArchived=true",
); );
expect([...counts.keys()]).toContain( expect(counts.keys()).toContain(
"/api/v1/bank-cards/a/operations?month=2026-09&page=2&limit=20", "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
); );
expect([...counts.keys()]).toContain( expect(counts.keys()).toContain(
"/api/v1/bank-cards/summary?month=2026-08&cardId=b", "/api/v1/bank-cards/summary?cardId=b&month=2026-08",
); );
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
}); });
it("replays the exact UUID and large Money string once across shared auth refresh", async () => { it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
let token = "expired"; let token = "expired";
const refresh = vi.fn(async () => { const refresh = vi.fn(() => {
token = "renewed"; token = "renewed";
return true; return Promise.resolve(true);
}); });
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = []; const bodies: unknown[] = [];
const headers: (string | null)[] = []; const headers: (null | string)[] = [];
server.use( server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => { http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json()); bodies.push(await request.json());
@@ -232,10 +234,8 @@ describe("banking API", () => {
it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => { it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => {
server.use(...readHandlers(new Map())); server.use(...readHandlers(new Map()));
let finish: () => void = () => {};
const pending = new Promise<void>((resolve) => { const { promise: pending, resolve: finish } = Promise.withResolvers<void>();
finish = resolve;
});
server.use( server.use(
http.post("*/api/v1/bank-cards/operations", async () => { http.post("*/api/v1/bank-cards/operations", async () => {
await pending; await pending;
@@ -243,7 +243,7 @@ describe("banking API", () => {
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ query: useBankCard("a"), mutations: useBankMutations() }), () => ({ mutations: useBankMutations(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
@@ -251,7 +251,8 @@ describe("banking API", () => {
act(() => { act(() => {
saving = result.current.mutations.createOperation saving = result.current.mutations.createOperation
.mutateAsync(transfer) .mutateAsync(transfer)
.catch(() => undefined); // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
}); });
await waitFor(() => await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true), expect(result.current.mutations.createOperation.isPending).toBe(true),
@@ -280,17 +281,18 @@ describe("banking API", () => {
HttpResponse.json({ ...transfer, version: 1 }), HttpResponse.json({ ...transfer, version: 1 }),
), ),
); );
const { wrapper, queryClient } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const brokerKey = ["get", "/api/v1/brokerage-accounts"]; const brokerKey = ["get", "/api/v1/brokerage-accounts"];
queryClient.setQueryData(brokerKey, []); queryClient.setQueryData(brokerKey, []);
const { result } = renderHook(useReadModels, { wrapper }); const { result } = renderHook(useReadModels, { wrapper });
await waitFor(() => await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
await act(() => await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer), result.current.mutations.createOperation.mutateAsync(transfer),
); );
expect([...counts.values()]).toEqual(Array(9).fill(2)); expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2);
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false); expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
}); });
@@ -316,8 +318,8 @@ describe("banking API", () => {
await act(async () => { await act(async () => {
await expect( await expect(
result.current.mutations.updateCard.mutateAsync({ result.current.mutations.updateCard.mutateAsync({
id: "a",
body: { name: "changed", version: 1 }, body: { name: "changed", version: 1 },
id: "a",
}), }),
).rejects.toEqual({ code }); ).rejects.toEqual({ code });
}); });
@@ -329,7 +331,7 @@ describe("banking API", () => {
let name = "Food"; let name = "Food";
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]), HttpResponse.json([{ id: "food", isDefault: true, name, version: 1 }]),
), ),
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => { http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
const body = await request.json(); const body = await request.json();
@@ -337,8 +339,8 @@ describe("banking API", () => {
name = "Groceries"; name = "Groceries";
return HttpResponse.json({ return HttpResponse.json({
id: "food", id: "food",
name,
isDefault: true, isDefault: true,
name,
version: 2, version: 2,
}); });
}), }),
@@ -366,8 +368,8 @@ describe("banking API", () => {
); );
await act(() => await act(() =>
result.current.mutations.updateCategory.mutateAsync({ result.current.mutations.updateCategory.mutateAsync({
id: "food",
body: { name: "Groceries", version: 1 }, body: { name: "Groceries", version: 1 },
id: "food",
}), }),
); );
await waitFor(() => await waitFor(() =>
@@ -375,8 +377,8 @@ describe("banking API", () => {
); );
await act(() => await act(() =>
result.current.mutations.updateOperation.mutateAsync({ result.current.mutations.updateOperation.mutateAsync({
id: "original",
body: { description: "corrected", version: 1 }, body: { description: "corrected", version: 1 },
id: "original",
}), }),
); );
await act(() => await act(() =>
@@ -403,8 +405,8 @@ describe("banking API", () => {
}), }),
), ),
); );
const { wrapper, queryClient } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const { result, rerender } = renderHook(() => useBankCard("a"), { const { rerender, result } = renderHook(() => useBankCard("a"), {
wrapper, wrapper,
}); });
await waitFor(() => expect(result.current.data?.balance.value).toBe("100")); await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
@@ -413,7 +415,7 @@ describe("banking API", () => {
}); });
expect(result.current.data?.balance.value).toBe("100"); expect(result.current.data?.balance.value).toBe("100");
act(() => { act(() => {
configureAuthClient(null); configureAuthClient();
queryClient.clear(); queryClient.clear();
}); });
rerender(); rerender();
+29 -25
View File
@@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, apiErrorCode } from "@/shared/api"; import { apiClient, apiErrorCode } from "@/shared/api";
import type { import type {
CreateBankCard, CreateBankCard,
CreateBankOperation, CreateBankOperation,
@@ -22,59 +24,61 @@ function useBankMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey: [method, path] }) => predicate: ({ queryKey }) => {
method === "get" && const [method, path] = queryKey;
typeof path === "string" && return method === "get"
(path === "/api/v1/bank-cards" || && typeof path === "string"
path.startsWith("/api/v1/bank-cards/")), && (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"));
},
}); });
const options = { const options = {
retry: false,
onSuccess: invalidate,
onError: (error: unknown) => { onError: (error: unknown) => {
if ( if (
[ [
"BANK_VERSION_CONFLICT", "BANK_DEPENDENT_REFUNDS",
"BANK_WRITE_CONFLICT",
"BANK_INTENT_CONFLICT", "BANK_INTENT_CONFLICT",
"BANK_REFUND_LIMIT", "BANK_REFUND_LIMIT",
"BANK_DEPENDENT_REFUNDS", "BANK_VERSION_CONFLICT",
"BANK_WRITE_CONFLICT",
].includes(apiErrorCode(error) ?? "") ].includes(apiErrorCode(error) ?? "")
) )
return invalidate(); return invalidate();
}, },
onSuccess: invalidate,
retry: false,
} as const; } as const;
const createCard = useMutation({ const cardCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateBankCard) => mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })), mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
}); });
const updateCard = useMutation({ const updateCard = useMutation({
...options, ...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) => mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData( mutationData(
apiClient.PATCH("/api/v1/bank-cards/{id}", { apiClient.PATCH("/api/v1/bank-cards/{id}", {
params: { path: { id } },
body, body,
params: { path: { id } },
}), }),
), ),
}); });
const createOperation = useMutation({ const operationCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateBankOperation) => mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })), mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
}); });
const updateOperation = useMutation({ const updateOperation = useMutation({
...options, ...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) => mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData( mutationData(
apiClient.PATCH("/api/v1/bank-cards/operations/{id}", { apiClient.PATCH("/api/v1/bank-cards/operations/{id}", {
params: { path: { id } },
body, body,
params: { path: { id } },
}), }),
), ),
}); });
const deleteOperation = useMutation({ const operationDeletion = useMutation({
...options, ...options,
mutationFn: ({ id, version }: { id: string; version: number }) => mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData( mutationData(
@@ -83,29 +87,29 @@ function useBankMutations() {
}), }),
), ),
}); });
const createCategory = useMutation({ const categoryCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateExpenseCategory) => mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })), mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
}); });
const updateCategory = useMutation({ const updateCategory = useMutation({
...options, ...options,
mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) => mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData( mutationData(
apiClient.PATCH("/api/v1/bank-cards/categories/{id}", { apiClient.PATCH("/api/v1/bank-cards/categories/{id}", {
params: { path: { id } },
body, body,
params: { path: { id } },
}), }),
), ),
}); });
return { return {
createCard, createCard: cardCreation,
createCategory: categoryCreation,
createOperation: operationCreation,
deleteOperation: operationDeletion,
updateCard, updateCard,
createOperation,
updateOperation,
deleteOperation,
createCategory,
updateCategory, updateCategory,
updateOperation,
}; };
} }
+125 -124
View File
@@ -7,39 +7,43 @@ import {
import { moneyCents } from "../model/form-validation"; import { moneyCents } from "../model/form-validation";
type BankCard = type BankCard
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation = type BankOperation
paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations = type BankOperations
paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary = type BankSummary
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type ExpenseCategory = type CreateBankCard
paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankCard = type CreateBankOperation
paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type UpdateBankCard = type CreateExpenseCategory
paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation = type ExpenseCategory
paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankOperation = type UpdateBankCard
paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory = type UpdateBankOperation
paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory = type UpdateExpenseCategory
paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100; const OPERATIONS_PAGE_SIZE = 100;
function useBankCards(includeArchived = false) { function sortBankSummary(data: BankSummary): BankSummary {
return useAuthenticatedQuery( return {
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { ...data,
params: { categories: data.categories.toSorted((a, b) => {
query: { includeArchived: includeArchived ? "true" : "false" }, const left = moneyCents(a.net.value);
}, const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
if (left > right) return -1;
return left < right ? 1 : 0;
}), }),
); };
} }
function useBankCard(id: string | undefined) { function useBankCard(id: string | undefined) {
@@ -51,79 +55,25 @@ function useBankCard(id: string | undefined) {
}); });
} }
function useBankCards(isIncludeArchived = false) {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: {
query: { includeArchived: isIncludeArchived ? "true" : "false" },
},
}),
);
}
function useBankCategories() { function useBankCategories() {
return useAuthenticatedQuery( return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"), apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"),
); );
} }
function useBankOperations(
id: string | undefined,
query: {
month?: string;
from?: string;
to?: string;
page: number;
limit?: number | "all";
},
) {
const all = query.limit === "all";
const params = {
path: { id: id ?? "" },
query: {
...query,
page: all ? 1 : query.page,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params },
);
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !all &&
previousQuery?.queryKey.length === options.queryKey.length &&
previous?.path?.id === id &&
previous?.query?.month === query.month &&
previous?.query?.from === query.from &&
previous?.query?.to === query.to &&
previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = params.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...params, query: { ...params.query, page } },
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!all) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, items, page: 1, hasNext: false };
}
},
enabled: Boolean(id && (query.month || (query.from && query.to))),
});
}
function useBankExpenses( function useBankExpenses(
id: string | undefined, id: string | undefined,
query: { page: number; limit?: number }, query: { limit?: number; page: number },
) { ) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", {
@@ -133,36 +83,6 @@ function useBankExpenses(
}); });
} }
function sortBankSummary(data: BankSummary): BankSummary {
return {
...data,
categories: data.categories.toSorted((a, b) => {
const left = moneyCents(a.net.value);
const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
return left > right ? -1 : left < right ? 1 : 0;
}),
};
}
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query =
typeof period === "string"
? { month: period, cardId }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
}),
select: sortBankSummary,
enabled: Boolean(period),
});
}
function useBankOperation(id: string | undefined) { function useBankOperation(id: string | undefined) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
@@ -174,6 +94,87 @@ function useBankOperation(id: string | undefined) {
}); });
} }
function useBankOperations(
id: string | undefined,
query: {
from?: string;
limit?: "all" | number;
month?: string;
page: number;
to?: string;
},
) {
const isAll = query.limit === "all";
const parameters = {
path: { id: id ?? "" },
query: {
...query,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
page: isAll ? 1 : query.page,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params: parameters },
);
// 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))),
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from
&& previous?.query?.to === query.to
&& previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...parameters, query: { ...parameters.query, page } },
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
}
},
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey,
});
}
function useBankSummary(
period: string | { 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 },
}),
enabled: Boolean(period),
select: sortBankSummary,
});
}
export { export {
type BankCard, type BankCard,
type BankOperation, type BankOperation,
+11 -8
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries"; import { type BankSummary, sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value });
it("orders displayed expenses descending, preserving cents, ties and source data", () => { it("orders displayed expenses descending, preserving cents, ties and source data", () => {
const values = [ const values = [
"-10", "-10",
@@ -10,23 +13,23 @@ it("orders displayed expenses descending, preserving cents, ties and source data
"-2", "-2",
"0", "0",
]; ];
const money = (value: string) => ({ currency: "RUB", value });
const data: BankSummary = { const data: BankSummary = {
month: "2026-09",
cardId: "a", cardId: "a",
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: values.map((value, index) => ({ categories: values.map((value, index) => ({
categoryId: String(index), categoryId: String(index),
categoryName: String(index), categoryName: String(index),
expenses: money("100"), expenses: money("100"),
refunds: money("0"),
net: money(value), net: money(value),
refunds: money("0"),
})), })),
expenses: money("0"),
month: "2026-09",
net: money("0"),
refunds: money("0"),
}; };
expect(sortBankSummary(data).categories.map((row) => row.categoryId)).toEqual( expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
["3", "1", "2", "5", "4", "0"], ["3", "1", "2", "5", "4", "0"],
); );
expect(data.categories.map((row) => row.net.value)).toEqual(values); expect(data.categories.map(row => row.net.value)).toEqual(values);
}); });
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
bankCardSchema, bankCardSchema,
bankDateInputValue, bankDateInputValue,
@@ -27,9 +28,9 @@ describe("bank form input", () => {
} }
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20"); expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
const values = { const values = {
name: "A",
bankName: "", bankName: "",
lastFour: "", lastFour: "",
name: "A",
openingBalance: "-92233720368547758.08", openingBalance: "-92233720368547758.08",
openingDate: "2026-01-01", openingDate: "2026-01-01",
}; };
+44 -39
View File
@@ -1,38 +1,43 @@
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
const ISO_DATE_LENGTH = 10;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
function bankDateInputValue(value: string) { function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
} }
function bankMonth(value: string | null) {
return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankMoneyValue(value: string) { function bankMoneyValue(value: string) {
return value return value
.trim() .trim()
.replace(",", ".") .replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1"); .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
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).format(new Date());
}
function moneyCents(value: string): bigint | null { function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", "."); const normalized = value.trim().replace(",", ".");
if (!/^-?\d+(\.\d{1,2})?$/.test(normalized)) return null; if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null;
const [whole, fraction = ""] = normalized.replace("-", "").split("."); const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT);
return ( return (
BigInt(`${whole}${fraction.padEnd(2, "0")}`) * BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
(normalized.startsWith("-") ? -1n : 1n) * (normalized.startsWith("-") ? -1n : 1n)
); );
} }
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
@@ -42,9 +47,9 @@ const moneySchema = z.string().refine(
(value) => { (value) => {
const cents = moneyCents(value); const cents = moneyCents(value);
return ( return (
cents !== null && cents !== null
cents >= MIN_BALANCE_KOPECKS && && cents >= MIN_BALANCE_KOPECKS
cents <= MAX_BALANCE_KOPECKS && cents <= MAX_BALANCE_KOPECKS
); );
}, },
{ error: validationMessage("bank.validation.money") }, { error: validationMessage("bank.validation.money") },
@@ -59,15 +64,25 @@ const positiveMoneySchema = moneySchema.refine(
const bankDateSchema = z const bankDateSchema = z
.string() .string()
.refine( .refine(
(value) => value =>
/^\d{4}-\d{2}-\d{2}$/.test(value) && /^\d{4}-\d{2}-\d{2}$/.test(value)
value >= "0001-01-01" && && value >= "0001-01-01"
!Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value && && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) === value
value <= bankToday(), && value <= bankToday(),
{ error: validationMessage("bank.validation.date") }, { error: validationMessage("bank.validation.date") },
); );
const bankCardSchema = z.object({ const bankCardSchema = z.object({
bankName: z
.string()
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
name: z name: z
.string() .string()
.trim() .trim()
@@ -76,29 +91,19 @@ const bankCardSchema = z.object({
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined), validationMessage("bank.validation.name")(undefined),
), ),
bankName: z openingBalance: moneySchema,
.string() openingDate: bankDateSchema,
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine((value) => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
paymentSystem: z paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], { .enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"), error: validationMessage("bank.validation.paymentSystem"),
}) })
.optional(), .optional(),
openingBalance: moneySchema,
openingDate: bankDateSchema,
}); });
type BankCardValues = z.infer<typeof bankCardSchema>; type BankCardValues = z.infer<typeof bankCardSchema>;
export { export {
type BankCardValues,
bankCardSchema, bankCardSchema,
type BankCardValues,
bankDateInputValue, bankDateInputValue,
bankDateSchema, bankDateSchema,
bankMoneyValue, bankMoneyValue,
@@ -1,140 +1,137 @@
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/queries";
type OperationIcon = { path: string; color: string; animation?: string }; type OperationIcon = { animation?: string; color: string; path: string };
const categoryIcons: Record<string, OperationIcon> = { const categoryIcons: Record<string, OperationIcon> = {
алкоголь: { "алкоголь": {
animation: "wine", animation: "wine",
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
color: "grape", color: "grape",
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
}, },
вклады: { "вклады": {
animation: "deposit", animation: "deposit",
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
color: "green", color: "green",
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
}, },
"жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" },
"здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" },
"инвестиции и накопления": { "инвестиции и накопления": {
animation: "growth", animation: "growth",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
color: "green", color: "green",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
}, },
образование: { "кафе": {
animation: "coffee",
color: "orange",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
},
"образование": {
color: "blue",
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9", path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
color: "blue",
}, },
переводы: { "переводы": {
animation: "transfer", animation: "transfer",
path: "M3 7h18l-4-4 M21 17H3l4 4",
color: "blue", color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
}, },
"погашение кредита": { "погашение кредита": {
animation: "repayment", animation: "repayment",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
color: "orange", color: "orange",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
}, },
подписки: { "подписки": {
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
color: "grape", color: "grape",
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
}, },
путешествия: { "покупки": {
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z", color: "grape",
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
},
"продукты": {
animation: "basket",
color: "green",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
},
"путешествия": {
color: "blue", color: "blue",
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
},
"развлечения": {
color: "grape",
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
}, },
"связь и интернет": { "связь и интернет": {
animation: "wifi", animation: "wifi",
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
color: "blue", color: "blue",
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
}, },
"снятие наличных": { "снятие наличных": {
color: "green",
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4", path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
color: "green",
}, },
ставки: { "ставки": {
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
color: "grape", color: "grape",
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
}, },
страхование: { "страхование": {
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
color: "green", color: "green",
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
}, },
табак: { "табак": {
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
color: "orange", color: "orange",
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
},
"такси": {
color: "yellow",
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
},
"транспорт": {
animation: "car",
color: "blue",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
}, },
"хостинг и ии-сервисы": { "хостинг и ии-сервисы": {
animation: "server", animation: "server",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
color: "grape", color: "grape",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
}, },
"цифровые покупки": { "цифровые покупки": {
color: "blue",
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3", path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
color: "blue",
},
продукты: {
animation: "basket",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
color: "green",
},
кафе: {
animation: "coffee",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
color: "orange",
},
такси: {
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
color: "yellow",
},
транспорт: {
animation: "car",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
color: "blue",
},
жилье: { path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", color: "orange" },
здоровье: { path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z", color: "red" },
покупки: {
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
color: "grape",
},
развлечения: {
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
color: "grape",
}, },
}; };
const otherIcon: OperationIcon = { const otherIcon: OperationIcon = {
animation: "grid", animation: "grid",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
color: "gray", color: "gray",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
}; };
const operationIcons: Record< const operationIcons: Record<
Exclude<BankOperation["kind"], "expense">, Exclude<BankOperation["kind"], "expense">,
OperationIcon OperationIcon
> = { > = {
refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" },
topup: { topup: {
animation: "topup", animation: "topup",
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
color: "green", color: "green",
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
}, },
transfer: { transfer: {
animation: "transfer", animation: "transfer",
path: "M3 7h18l-4-4 M21 17H3l4 4",
color: "blue", color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
}, },
refund: { path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", color: "teal" },
}; };
function bankOperationAppearance( function bankOperationAppearance(
kind: BankOperation["kind"], kind: BankOperation["kind"],
categoryName?: string | null, categoryName?: null | string,
) { ) {
if (kind !== "expense") return /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ operationIcons[kind];
const name = categoryName const name = categoryName
?.trim() ?.trim()
.toLocaleLowerCase("ru-RU") .toLocaleLowerCase("ru-RU")
.replaceAll("ё", "е"); .replaceAll("ё", "е");
return kind === "expense" return name && Object.hasOwn(categoryIcons, name) ? /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ categoryIcons[name] : otherIcon;
? name && Object.hasOwn(categoryIcons, name)
? categoryIcons[name]
: otherIcon
: operationIcons[kind];
} }
export { bankOperationAppearance }; export { bankOperationAppearance };
@@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from "vitest"; import { afterEach, expect, it, vi } from "vitest";
import { bankPeriod } from "./period"; import { bankPeriod } from "./period";
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
+12 -11
View File
@@ -1,20 +1,21 @@
import { z } from "zod"; import { z } from "zod";
import { bankMonth } from "./form-validation"; import { bankMonth } from "./form-validation";
const calendarDate = z.string().date(); const calendarDate = z.iso.date();
const ISO_DATE_LENGTH = 10; const ISO_DATE_LENGTH = 10;
function bankPeriod(params: URLSearchParams) { function bankPeriod(parameters: URLSearchParams) {
const month = bankMonth(params.get("month")); const month = bankMonth(parameters.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`); const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0); monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = params.get("from"); const from = parameters.get("from");
const to = params.get("to"); const to = parameters.get("to");
return from && return from
to && && to
calendarDate.safeParse(from).success && && calendarDate.safeParse(from).success
calendarDate.safeParse(to).success && && calendarDate.safeParse(to).success
from >= "0001-01-01" && && from >= "0001-01-01"
from <= to && from <= to
? { from, to } ? { from, to }
: { : {
from: `${month}-01`, from: `${month}-01`,
@@ -1,67 +1,75 @@
import { SimpleGrid } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { SimpleGrid } from "@mantine/core";
import { BankCardFace } from "./BankCardFace"; import { BankCardFace } from "./BankCardFace";
const meta = { const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
title: "Product/BankCards/CardAppearance", {
component: BankCardFace,
tags: ["autodocs"],
args: { args: {
card: { card: {
id: "fictional",
name: "Для повседневных покупок",
bankName: "Сбербанк",
lastFour: "4829",
paymentSystem: "mir",
openingBalance: { value: "0", currency: "RUB" },
openingDate: "2026-01-01",
balance: { value: "128450.75", currency: "RUB" },
archivedAt: null, archivedAt: null,
balance: { currency: "RUB", value: "128450.75" },
bankName: "Сбербанк",
id: "fictional",
lastFour: "4829",
name: "Для повседневных покупок",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: "mir",
version: 1, version: 1,
}, },
}, },
} satisfies Meta<typeof BankCardFace>; component: BankCardFace,
tags: ["autodocs"],
title: "Product/BankCards/CardAppearance",
}
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankCardFace>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Banks: Story = { export const Banks: Story = {
render: (args) => ( render: arguments_ => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}> <SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", null].map( {["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null].map(
(bankName, index) => ( (bankName, index) => (
<BankCardFace <BankCardFace
card={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...arguments_.card,
bankName,
paymentSystem:
/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ (["mir", "mastercard", "visa", "mir", null] as const)[index]
?? null,
}
/* eslint-enable unicorn/no-null */)}
key={bankName ?? "unknown"} key={bankName ?? "unknown"}
card={{
...args.card,
bankName,
paymentSystem:
(["mir", "mastercard", "visa", "mir", null] as const)[index] ??
null,
}}
/> />
), ),
)} )}
</SimpleGrid> </SimpleGrid>
), ),
}; };
export const MissingDetails: Story = { export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
args: { {
card: { args: {
...meta.args.card, card: {
bankName: null, ...meta.args.card,
lastFour: null, bankName: null,
paymentSystem: null, lastFour: null,
paymentSystem: null,
},
}, },
}, }
}; /* eslint-enable unicorn/no-null */);
export const LongArchived: Story = { export const LongArchived: Story = {
args: { args: {
card: { card: {
...meta.args.card, ...meta.args.card,
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
archivedAt: "2026-09-12", archivedAt: "2026-09-12",
balance: { currency: "RUB", value: "184467440737095516.14" },
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
paymentSystem: "mastercard", paymentSystem: "mastercard",
balance: { value: "184467440737095516.14", currency: "RUB" },
}, },
}, },
}; };
@@ -1,7 +1,11 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/queries";
import classes from "./BankCardFace.module.css"; import classes from "./BankCardFace.module.css";
type BankCardFaceProps = { card: BankCard }; type BankCardFaceProps = { card: BankCard };
@@ -9,15 +13,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
const { card } = props; const { card } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const bank = card.bankName?.toLocaleLowerCase("ru") ?? ""; const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
const tone = /сбер|sber/.test(bank) const tone = bankTone(bank);
? "green"
: /тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)
? "yellow"
: /альфа|alfa/.test(bank)
? "red"
: /втб|vtb|газпром|gazprom/.test(bank)
? "blue"
: "graphite";
return ( return (
<div className={classes.face} data-tone={tone}> <div className={classes.face} data-tone={tone}>
<div className={classes.top}> <div className={classes.top}>
@@ -26,26 +22,28 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
<span className={classes.archive}>{t("bank.archived")}</span> <span className={classes.archive}>{t("bank.archived")}</span>
)} )}
</div> </div>
<div className={classes.hardware} aria-hidden="true"> <div aria-hidden="true" className={classes.hardware}>
<span className={classes.chip} /> <span className={classes.chip} />
<svg <svg
aria-hidden="true" aria-hidden="true"
width="24" fill="none"
height="30" height="30"
viewBox="0 0 24 30" viewBox="0 0 24 30"
fill="none" width="24"
> >
<path <path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24" d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor" stroke="currentColor"
strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeWidth="2"
/> />
</svg> </svg>
</div> </div>
<div <div
className={classes.number} className={classes.number}
>{`•••• •••• •••• ${card.lastFour || "••••"}`}</div> >
{`•••• •••• •••• ${card.lastFour || "••••"}`}
</div>
<div className={classes.bottom}> <div className={classes.bottom}>
<div className={classes.details}> <div className={classes.details}>
<div className={classes.name}>{card.name}</div> <div className={classes.name}>{card.name}</div>
@@ -54,7 +52,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
{card.paymentSystem && ( {card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}> <span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && ( {card.paymentSystem === "mastercard" && (
<span className={classes.circles} aria-hidden="true" /> <span aria-hidden="true" className={classes.circles} />
)} )}
{t(`bank.paymentSystems.${card.paymentSystem}`)} {t(`bank.paymentSystems.${card.paymentSystem}`)}
</span> </span>
@@ -64,4 +62,11 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
); );
}; };
function bankTone(bank: string) {
if (/сбер|sber/.test(bank)) return "green";
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
if (/альфа|alfa/.test(bank)) return "red";
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
}
export { BankCardFace }; export { BankCardFace };
@@ -1,20 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import type { BankCardValues } from "../../model/form-validation"; import type { BankCardValues } from "../../model/form-validation";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
const meta = { const meta = {
title: "Product/BankCards/CardFields",
component: BankCardFields,
tags: ["autodocs"],
args: { archived: false }, args: { archived: false },
component: BankCardFields,
decorators: [ decorators: [
(Story) => { (Story) => {
const methods = useForm<BankCardValues>({ const methods = useForm<BankCardValues>({
defaultValues: { defaultValues: {
name: "Учебная повседневная карта",
bankName: "Вымышленный банк", bankName: "Вымышленный банк",
lastFour: "1234", lastFour: "1234",
name: "Учебная повседневная карта",
openingBalance: "-92233720368547758.08", openingBalance: "-92233720368547758.08",
openingDate: "2026-08-01", openingDate: "2026-08-01",
}, },
@@ -34,11 +35,13 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Product/BankCards/CardFields",
} satisfies Meta<typeof BankCardFields>; } satisfies Meta<typeof BankCardFields>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" }; export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = { export const Archived: Story = {
name: "Финансовые поля архивной карты",
args: { archived: true }, args: { archived: true },
name: "Финансовые поля архивной карты",
}; };
@@ -1,31 +1,36 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm"; import { BankCardForm } from "./BankCardForm";
const card: BankCard = { const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id: "a", {
name: "Основная", archivedAt: null,
bankName: "Банк", balance: { currency: "RUB", value: "50" },
lastFour: "1234", bankName: "Банк",
paymentSystem: null, id: "a",
openingBalance: { currency: "RUB", value: "100" }, lastFour: "1234",
balance: { currency: "RUB", value: "50" }, name: "Основная",
openingDate: "2026-01-01", openingBalance: { currency: "RUB", value: "100" },
archivedAt: null, openingDate: "2026-01-01",
version: 1, paymentSystem: null,
}; version: 1,
}
/* eslint-enable unicorn/no-null */);
function setup(value: BankCard) { function setup(value: BankCard) {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
refresh: async () => false,
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false),
}); });
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value))); server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
const close = vi.fn(); const close = vi.fn();
@@ -64,13 +69,15 @@ describe("BankCardForm", () => {
); );
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled()); await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toEqual({ expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
version: 1, {
name: "Основная", bankName: null,
bankName: null, lastFour: null,
lastFour: null, name: "Основная",
paymentSystem: null, paymentSystem: null,
}); version: 1,
}
/* eslint-enable unicorn/no-null */));
}); });
it("loads and saves the selected payment system", async () => { it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" }); const close = setup({ ...card, paymentSystem: "mir" });
@@ -146,6 +153,6 @@ describe("BankCardForm", () => {
fireEvent.click(adopt); fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled()); await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies.map((b) => b.version)).toEqual([1, 2]); expect(bodies.map(b => b.version)).toEqual([1, 2]);
}); });
}); });
@@ -3,12 +3,14 @@ import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations"; import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries"; import { type BankCard, useBankCard } from "../../api/queries";
import { import {
type BankCardValues,
bankCardSchema, bankCardSchema,
type BankCardValues,
bankMoneyValue, bankMoneyValue,
bankToday, bankToday,
} from "../../model/form-validation"; } from "../../model/form-validation";
@@ -29,29 +31,29 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
const fresh = useBankCard(props.card?.id); const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations(); const mutation = useBankMutations();
const methods = useForm<BankCardValues>({ const methods = useForm<BankCardValues>({
resolver: zodResolver(bankCardSchema),
defaultValues: { defaultValues: {
name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "", bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "", lastFour: props.card?.lastFour ?? "",
paymentSystem: props.card?.paymentSystem ?? "", name: props.card?.name ?? "",
openingBalance: props.card?.openingBalance.value ?? "0", openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(), openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
}, },
resolver: zodResolver(bankCardSchema),
}); });
const pending = const isPending
!opened || = !opened
methods.formState.isSubmitting || || methods.formState.isSubmitting
(fresh.isFetching && Boolean(error)); || (fresh.isFetching && Boolean(error));
return ( return (
<Modal <Modal
opened={opened} closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onExitTransitionEnd={props.onClose} onExitTransitionEnd={props.onClose}
onClose={() => !pending && setOpened(false)} opened={opened}
title={t(props.card ? "bank.editCard" : "bank.addCard")} title={t(props.card ? "bank.editCard" : "bank.addCard")}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
@@ -59,10 +61,10 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
setError(undefined); setError(undefined);
try { try {
const metadata = { const metadata = {
bankName: values.bankName || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
lastFour: values.lastFour || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
name: values.name, name: values.name,
bankName: values.bankName || null, paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
lastFour: values.lastFour || null,
paymentSystem: values.paymentSystem || null,
}; };
const financial = { const financial = {
openingBalance: { openingBalance: {
@@ -73,12 +75,12 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
}; };
if (props.card) if (props.card)
await mutation.updateCard.mutateAsync({ await mutation.updateCard.mutateAsync({
id: props.card.id,
body: { body: {
...metadata, ...metadata,
...(!props.card.archivedAt ? financial : {}), ...(!props.card.archivedAt && financial),
version, version,
}, },
id: props.card.id,
}); });
else else
await mutation.createCard.mutateAsync({ await mutation.createCard.mutateAsync({
@@ -87,32 +89,33 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
...financial, ...financial,
}); });
setOpened(false); setOpened(false);
} catch (failure) { }
setError(failure); catch (error_) {
setError(error_);
} }
})} })}
> >
<fieldset className={classes.fieldset} disabled={pending}> <fieldset className={classes.fieldset} disabled={isPending}>
<Stack> <Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} /> <BankCardFields archived={Boolean(props.card?.archivedAt)} />
{error ? ( {!!(error) && ((
<> <>
<BankFeedback error={error} /> <BankFeedback error={error} />
{props.card && ( {props.card && (
<Button <Button
variant="default"
onClick={async () => { onClick={async () => {
const response = await fresh.refetch(); const response = await fresh.refetch();
if (response.data && !response.error) if (response.data && !response.error)
setLatest(response.data); setLatest(response.data);
else setError(response.error); else setError(response.error);
}} }}
variant="default"
> >
{t("bank.reloadRecord")} {t("bank.reloadRecord")}
</Button> </Button>
)} )}
</> </>
) : null} ))}
{latest && ( {latest && (
<> <>
<Text> <Text>
@@ -121,22 +124,22 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
})} })}
</Text> </Text>
<Button <Button
variant="default"
onClick={() => { onClick={() => {
setVersion(latest.version); setVersion(latest.version);
setLatest(undefined); setLatest(undefined);
setError(undefined); setError(undefined);
}} }}
variant="default"
> >
{t("bank.adoptVersion", { version: latest.version })} {t("bank.adoptVersion", { version: latest.version })}
</Button> </Button>
</> </>
)} )}
<Group> <Group>
<Button type="submit" loading={pending}> <Button loading={isPending} type="submit">
{t("bank.save")} {t("bank.save")}
</Button> </Button>
<Button variant="default" onClick={() => setOpened(false)}> <Button onClick={() => setOpened(false)} variant="default">
{t("bank.cancel")} {t("bank.cancel")}
</Button> </Button>
</Group> </Group>
@@ -1,8 +1,10 @@
import type { FC } from "react";
import { Select, Stack, Text, TextInput } from "@mantine/core"; import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
type BankCardValues, type BankCardValues,
bankDateInputValue, bankDateInputValue,
@@ -14,8 +16,8 @@ type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => { const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const { const {
control, control,
register,
formState: { errors }, formState: { errors },
register,
} = useFormContext<BankCardValues>(); } = useFormContext<BankCardValues>();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -32,8 +34,8 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
error={errors.bankName?.message} error={errors.bankName?.message}
/> />
<TextInput <TextInput
label={t("bank.lastFour")}
inputMode="numeric" inputMode="numeric"
label={t("bank.lastFour")}
{...register("lastFour")} {...register("lastFour")}
error={errors.lastFour?.message} error={errors.lastFour?.message}
/> />
@@ -43,25 +45,25 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
render={({ field }) => ( render={({ field }) => (
<Select <Select
{...field} {...field}
value={field.value || null}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.paymentSystem")}
placeholder={t("bank.paymentSystemUnknown")}
clearable clearable
data={["mir", "visa", "mastercard"].map((value) => ({
value,
label: t(`bank.paymentSystems.${value}`),
}))}
comboboxProps={{ withinPortal: false }} comboboxProps={{ withinPortal: false }}
data={["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}))}
error={errors.paymentSystem?.message} error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={value => field.onChange(value ?? "")}
placeholder={t("bank.paymentSystemUnknown")}
value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
/> />
)} )}
/> />
<TextInput <TextInput
label={t("bank.openingBalance")}
required
inputMode="decimal" inputMode="decimal"
label={t("bank.openingBalance")}
readOnly={props.archived} readOnly={props.archived}
required
{...register("openingBalance")} {...register("openingBalance")}
error={errors.openingBalance?.message} error={errors.openingBalance?.message}
/> />
@@ -71,37 +73,35 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
render={({ field }) => ( render={({ field }) => (
<DateInput <DateInput
{...field} {...field}
label={t("bank.openingDate")} dateParser={value =>
required
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
dateParser={(value) =>
bankDateSchema.safeParse(bankDateInputValue(value)).success bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value) ? bankDateInputValue(value)
: null : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
} error={errors.openingDate?.message}
fixOnBlur={false} fixOnBlur={false}
onInput={(event) => label={t("bank.openingDate")}
field.onChange(bankDateInputValue(event.currentTarget.value)) maxDate={bankToday()}
}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => { onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value)); field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur(); field.onBlur();
}} }}
maxDate={bankToday()} onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(bankDateInputValue(event.currentTarget.value))}
popoverProps={{ popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}} }}
readOnly={props.archived} readOnly={props.archived}
error={errors.openingDate?.message} required
value={
(bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined
}
valueFormat="DD.MM.YYYY"
/> />
)} )}
/> />
<Text size="sm" c="dimmed"> <Text c="dimmed" size="sm">
{t("bank.openingHint")} {t("bank.openingHint")}
</Text> </Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>} {props.archived && <Text>{t("bank.archiveHint")}</Text>}
+6 -4
View File
@@ -1,13 +1,15 @@
import { Alert, Button, Group, Text } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
type BankFeedbackProps = { type BankFeedbackProps = {
error: unknown;
retry?: () => void;
cached?: boolean; cached?: boolean;
error: unknown;
loadingError?: boolean; loadingError?: boolean;
retry?: () => void;
}; };
const BankFeedback: FC<BankFeedbackProps> = (props) => { const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,7 +27,7 @@ const BankFeedback: FC<BankFeedbackProps> = (props) => {
})} })}
</Text> </Text>
{props.retry && ( {props.retry && (
<Button variant="default" onClick={props.retry}> <Button onClick={props.retry} variant="default">
{t("bank.retry")} {t("bank.retry")}
</Button> </Button>
)} )}
@@ -1,13 +1,15 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui"; import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon"; import { BankOperationIcon } from "./BankOperationIcon";
const meta = { const meta = {
title: "Product/BankCards/OperationIcons", args: { categoryName: "Продукты", kind: "expense" },
component: BankOperationIcon, component: BankOperationIcon,
tags: ["autodocs"], tags: ["autodocs"],
args: { kind: "expense", categoryName: "Продукты" }, title: "Product/BankCards/OperationIcons",
} satisfies Meta<typeof BankOperationIcon>; } satisfies Meta<typeof BankOperationIcon>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
@@ -40,28 +42,28 @@ export const Categories: Story = {
"Хостинг и ИИ-сервисы", "Хостинг и ИИ-сервисы",
"Цифровые покупки", "Цифровые покупки",
"Моя категория", "Моя категория",
].map((name) => ( ].map(name => (
<EntryCard <EntryCard
key={name}
title="Расход"
metadata={`12.09.2026 · ${name}`}
initial="Р"
color="red" color="red"
icon={<BankOperationIcon kind="expense" categoryName={name} />} icon={<BankOperationIcon categoryName={name} kind="expense" />}
initial="Р"
key={name}
metadata={`12.09.2026 · ${name}`}
title="Расход"
tone="negative"
value="−1 250,00 ₽" value="−1 250,00 ₽"
valueLabel="Расход" valueLabel="Расход"
tone="negative"
/> />
))} ))}
<EntryCard <EntryCard
title="Пополнение"
metadata="12.09.2026"
initial="П"
color="green" color="green"
icon={<BankOperationIcon kind="topup" />} icon={<BankOperationIcon kind="topup" />}
initial="П"
metadata="12.09.2026"
title="Пополнение"
tone="positive"
value="+15 000,00 ₽" value="+15 000,00 ₽"
valueLabel="Пополнение" valueLabel="Пополнение"
tone="positive"
/> />
</Stack> </Stack>
), ),
@@ -1,36 +1,40 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { BankOperationIcon } from "./BankOperationIcon"; import { BankOperationIcon } from "./BankOperationIcon";
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => { it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
const { container, rerender } = render( const { container, rerender } = render(
<BankOperationIcon kind="expense" categoryName="Жильё" />, <BankOperationIcon categoryName="Жильё" kind="expense" />,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = () => container.querySelector("path")?.getAttribute("d"); const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path(); const housing = path();
rerender(<BankOperationIcon kind="expense" categoryName=" ЖИЛЬЕ " />); rerender(<BankOperationIcon categoryName=" ЖИЛЬЕ " kind="expense" />);
expect(path()).toBe(housing); expect(path()).toBe(housing);
rerender(<BankOperationIcon kind="expense" categoryName="Другое" />); rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path(); const fallback = path();
expect(fallback).not.toBe(housing); expect(fallback).not.toBe(housing);
for (const categoryName of ["Моя категория", "constructor", null]) { for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />); rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
expect(path()).toBe(fallback); expect(path()).toBe(fallback);
} }
const paths = [fallback, housing]; const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) { for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon kind={kind} categoryName="Жильё" />); rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
expect(path()).toBeTruthy(); expect(path()).toBeTruthy();
paths.push(path()); paths.push(path());
} }
expect(new Set(paths).size).toBe(5); expect(new Set(paths).size).toBe(5);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
}); });
it("provides a distinct icon for every existing named category", () => { it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render( const { container, rerender } = render(
<BankOperationIcon kind="expense" categoryName="Другое" />, <BankOperationIcon categoryName="Другое" kind="expense" />,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const paths = new Set([container.querySelector("path")?.getAttribute("d")]); const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
for (const categoryName of [ for (const categoryName of [
"Алкоголь", "Алкоголь",
@@ -57,7 +61,8 @@ it("provides a distinct icon for every existing named category", () => {
"Хостинг и ИИ-сервисы", "Хостинг и ИИ-сервисы",
"Цифровые покупки", "Цифровые покупки",
]) { ]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />); rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = container.querySelector("path")?.getAttribute("d"); const path = container.querySelector("path")?.getAttribute("d");
expect(path).toBeTruthy(); expect(path).toBeTruthy();
expect(paths.has(path), categoryName).toBe(false); expect(paths.has(path), categoryName).toBe(false);
@@ -1,34 +1,36 @@
import type { FC } from "react"; import type { FC } from "react";
import { bindIconAnimation } from "@/shared/lib"; import { bindIconAnimation } from "@/shared/lib";
import type { BankOperation } from "../../api/queries"; import type { BankOperation } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankOperationIcon.module.css"; import classes from "./BankOperationIcon.module.css";
type BankOperationIconProps = { type BankOperationIconProps = {
categoryName?: null | string;
kind: BankOperation["kind"]; kind: BankOperation["kind"];
categoryName?: string | null;
}; };
const BankOperationIcon: FC<BankOperationIconProps> = (props) => { const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
const icon = bankOperationAppearance(props.kind, props.categoryName); const icon = bankOperationAppearance(props.kind, props.categoryName);
return ( return (
<svg <svg
ref={bindIconAnimation} aria-hidden="true"
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`} className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.6"
focusable="false" style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
style={{ flexShrink: 0, color: `var(--mantine-color-${icon.color}-7)` }} viewBox="0 0 24 24"
width="24"
> >
{icon.path.split(" M").map((part, index) => ( {icon.path.split(" M").map((part, index) => (
<path key={part} d={index ? `M${part}` : part} pathLength={1} /> <path d={index ? `M${part}` : part} key={part} pathLength={1} />
))} ))}
</svg> </svg>
); );
@@ -3,34 +3,34 @@ import { type FC, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type BankPeriodPickerProps = { type BankPeriodPickerProps = {
onChange: (value: [null | string, null | string]) => void;
period: { from: string; to: string }; period: { from: string; to: string };
onChange: (value: [string | null, string | null]) => void;
}; };
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => { const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
const { period, onChange } = props; const { onChange, period } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const scope = `${period.from}-${period.to}`; const scope = `${period.from}-${period.to}`;
const [draft, setDraft] = useState<{ const [draft, setDraft] = useState<{
scope: string; scope: string;
value: [string | null, string | null]; value: [null | string, null | string];
}>(); }>();
const value: [string | null, string | null] = const value: [null | string, null | string]
draft?.scope === scope ? draft.value : [period.from, period.to]; = draft?.scope === scope ? draft.value : [period.from, period.to];
return ( return (
<DatePickerInput <DatePickerInput
type="range"
allowSingleDateInRange allowSingleDateInRange
label={t("bank.period")} label={t("bank.period")}
valueFormat="DD.MM.YYYY"
value={value}
onChange={(next) => { onChange={(next) => {
setDraft({ scope, value: next }); setDraft({ scope, value: next });
onChange(next); onChange(next);
}} }}
popoverProps={{ popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}} }}
type="range"
value={value}
valueFormat="DD.MM.YYYY"
/> />
); );
}; };
@@ -1,11 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { BankSummaryView } from "./BankSummary.view"; import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value }); const money = (value: string) => ({ currency: "RUB", value });
const meta = { const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
title: "Product/BankCards/Summary", {
args: {
data: {
cardId: null,
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
net: money("2500"),
refunds: money("1000"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
net: money("2200"),
refunds: money("0"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
net: money("1800"),
refunds: money("0"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
net: money("1000"),
refunds: money("0"),
},
],
expenses: money("8500"),
month: "2026-09",
net: money("7500"),
refunds: money("1000"),
},
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView, component: BankSummaryView,
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -14,118 +57,84 @@ const meta = {
}, },
}, },
}, },
args: { tags: ["autodocs"],
loading: false, title: "Product/BankCards/Summary",
error: false, }
fetching: false, /* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankSummaryView>;
retry: () => {},
data: {
month: "2026-09",
cardId: null,
expenses: money("8500"),
refunds: money("1000"),
net: money("7500"),
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
refunds: money("1000"),
net: money("2500"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
refunds: money("0"),
net: money("2200"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
refunds: money("0"),
net: money("1800"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
refunds: money("0"),
net: money("1000"),
},
],
},
},
} satisfies Meta<typeof BankSummaryView>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Loaded: Story = { name: "Расходы по категориям" }; export const Loaded: Story = { name: "Расходы по категориям" };
export const RefundMonth: Story = { export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
name: "Возврат покупки прошлого месяца", {
args: { args: {
data: { data: {
month: "2026-09", cardId: null,
cardId: null, categories: [
expenses: money("0"), {
refunds: money("600"), categoryId: "shopping",
net: money("-600"), categoryName:
categories: [
{
categoryId: "shopping",
categoryName:
"Покупки с длинным пользовательским названием категории", "Покупки с длинным пользовательским названием категории",
expenses: money("0"), expenses: money("0"),
refunds: money("600"), net: money("-600"),
net: money("-600"), refunds: money("600"),
}, },
], ],
expenses: money("0"),
month: "2026-09",
net: money("-600"),
refunds: money("600"),
},
}, },
}, name: "Возврат покупки прошлого месяца",
}; }
export const Empty: Story = { /* eslint-enable unicorn/no-null */);
name: "Известные нули", export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
args: { {
data: { args: {
month: "2026-09", data: {
cardId: null, cardId: null,
expenses: money("0"), categories: [],
refunds: money("0"), expenses: money("0"),
net: money("0"), month: "2026-09",
categories: [], net: money("0"),
refunds: money("0"),
},
}, },
}, name: "Известные нули",
}; }
/* eslint-enable unicorn/no-null */);
export const Loading: Story = { export const Loading: Story = {
name: "Начальная загрузка",
args: { data: undefined, loading: true }, args: { data: undefined, loading: true },
name: "Начальная загрузка",
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка без данных",
args: { data: undefined, error: true }, args: { data: undefined, error: true },
name: "Ошибка без данных",
}; };
export const CachedError: Story = { export const CachedError: Story = {
name: "Сохранённые суммы при ошибке обновления",
args: { error: true }, args: { error: true },
name: "Сохранённые суммы при ошибке обновления",
}; };
export const LongAmounts: Story = { export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
name: "Точный итог длиннее предела отдельного ввода", {
args: { args: {
data: { data: {
month: "2026-09", cardId: null,
cardId: null, categories: [
expenses: money("184467440737095516.14"), {
refunds: money("0"), categoryId: "shopping",
net: money("184467440737095516.14"), categoryName: "Покупки",
categories: [ expenses: money("184467440737095516.14"),
{ net: money("184467440737095516.14"),
categoryId: "shopping", refunds: money("0"),
categoryName: "Покупки", },
expenses: money("184467440737095516.14"), ],
refunds: money("0"), expenses: money("184467440737095516.14"),
net: money("184467440737095516.14"), month: "2026-09",
}, net: money("184467440737095516.14"),
], refunds: money("0"),
},
}, },
}, name: "Точный итог длиннее предела отдельного ввода",
}; }
/* eslint-enable unicorn/no-null */);
@@ -1,24 +1,78 @@
import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import type { CSSProperties, FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries"; import type { BankSummary } from "../../api/queries";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon"; import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2;
type BankSummaryViewProps = { type BankSummaryViewProps = {
all?: boolean;
data?: BankSummary; data?: BankSummary;
loading: boolean;
error: boolean; error: boolean;
fetching: boolean; fetching: boolean;
loading: boolean;
retry: () => void; retry: () => void;
all?: boolean;
}; };
const BankSummaryView: FC<BankSummaryViewProps> = (props) => { const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
function renderCategories() {
if (props.data) {
if (props.data.categories.length > 0) {
return (
<dl
className={classes.categories}
style={
{
"--category-rows": Math.ceil(props.data.categories.length / CATEGORY_COLUMNS),
} as CSSProperties
}
>
{props.data.categories.map(category => (
<div className={classes.category} key={category.categoryId}>
<dt className={classes.label}>
<BankOperationIcon
categoryName={category.categoryName}
kind="expense"
/>
<span>{category.categoryName}</span>
</dt>
<dd className={classes.amount}>
{formatMoney(category.net, { withZeroCents: true })}
</dd>
</div>
))}
</dl>
);
}
return (
<Text c="dimmed" size="sm">
{t("bank.noExpenses")}
</Text>
);
}
if (props.loading) {
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack gap="sm">
<Skeleton h={32} w="35%" />
<Skeleton h={40} />
<Skeleton h={40} />
</Stack>
</LoadingSkeleton>
);
}
}
return ( return (
<Stack gap="md" aria-busy={props.loading || props.fetching}> <Stack aria-busy={props.loading || props.fetching} gap="md">
<div className={classes.header}> <div className={classes.header}>
<Title order={2}> <Title order={2}>
{t(props.all ? "bank.allSummary" : "bank.summary")} {t(props.all ? "bank.allSummary" : "bank.summary")}
@@ -29,49 +83,11 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Text> </Text>
)} )}
</div> </div>
{props.data ? ( {renderCategories()}
props.data.categories.length ? (
<dl
className={classes.categories}
style={
{
"--category-rows": Math.ceil(props.data.categories.length / 2),
} as CSSProperties
}
>
{props.data.categories.map((category) => (
<div className={classes.category} key={category.categoryId}>
<dt className={classes.label}>
<BankOperationIcon
kind="expense"
categoryName={category.categoryName}
/>
<span>{category.categoryName}</span>
</dt>
<dd className={classes.amount}>
{formatMoney(category.net, { withZeroCents: true })}
</dd>
</div>
))}
</dl>
) : (
<Text size="sm" c="dimmed">
{t("bank.noExpenses")}
</Text>
)
) : props.loading ? (
<LoadingSkeleton label={t("bank.loading")}>
<Stack gap="sm">
<Skeleton h={32} w="35%" />
<Skeleton h={40} />
<Skeleton h={40} />
</Stack>
</LoadingSkeleton>
) : null}
{props.error && ( {props.error && (
<Group role="alert"> <Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text> <Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button variant="default" onClick={props.retry}> <Button onClick={props.retry} variant="default">
{t("bank.retry")} {t("bank.retry")}
</Button> </Button>
</Group> </Group>
@@ -7,36 +7,36 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager"; import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => { describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => { it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
refresh: async () => false,
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false),
}); });
const categories = [ const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 }, { id: "a", isDefault: false, name: "Первая", version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 }, { id: "b", isDefault: false, name: "Вторая", version: 4 },
]; ];
let reads = 0; let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => { const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
release = resolve; const patches: { body: unknown; id: unknown }[] = [];
});
const patches: { id: unknown; body: unknown }[] = [];
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", async () => { http.get("*/api/v1/bank-cards/categories", async () => {
reads++; reads++;
if (reads === 3) await reloaded; if (reads === 3) await reloaded;
return HttpResponse.json( return HttpResponse.json(
categories.map((category) => categories.map(category =>
category.id === "a" && reads > 1 category.id === "a" && reads > 1
? { ? {
...category, ...category,
@@ -49,8 +49,8 @@ describe("CategoryManager", () => {
}), }),
http.patch( http.patch(
"*/api/v1/bank-cards/categories/:id", "*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => { async ({ params, request }) => {
patches.push({ id: params.id, body: await request.json() }); patches.push({ body: await request.json(), id: params.id });
return patches.length === 1 return patches.length === 1
? HttpResponse.json( ? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" }, { code: "BANK_VERSION_CONFLICT" },
@@ -67,9 +67,8 @@ describe("CategoryManager", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
fireEvent.click( const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
(await screen.findAllByRole("button", { name: "Переименовать" }))[0], fireEvent.click(renameButtons[0]);
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }), await screen.findByRole("button", { name: "Перечитать запись" }),
@@ -99,8 +98,8 @@ describe("CategoryManager", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => await waitFor(() =>
expect(patches).toEqual([ expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } }, { body: { name: "Первая", version: 1 }, id: "a" },
{ id: "b", body: { name: "Новая вторая", version: 4 } }, { body: { name: "Новая вторая", version: 4 }, id: "b" },
]), ]),
); );
}); });
@@ -110,15 +109,15 @@ describe("CategoryManager", () => {
async (action) => { async (action) => {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
refresh: async () => false,
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false),
}); });
let version = 1; let version = 1;
let writes = 0; let writes = 0;
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([ HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version }, { id: "a", isDefault: false, name: "Первая", version },
]), ]),
), ),
http.patch("*/api/v1/bank-cards/categories/a", () => { http.patch("*/api/v1/bank-cards/categories/a", () => {
@@ -161,13 +160,13 @@ describe("CategoryManager", () => {
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
refresh: async () => false,
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false),
}); });
let category = { let category = {
id: "default", id: "default",
name: "Другое",
isDefault: true, isDefault: true,
name: "Другое",
version: 1, version: 1,
}; };
let creates = 0; let creates = 0;
@@ -17,7 +17,9 @@ import {
} from "react-hook-form"; } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { useBankMutations } from "../../api/mutations"; import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries"; import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
@@ -60,44 +62,40 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories(); const categories = useBankCategories();
const mutation = useBankMutations(); const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>(); const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0); const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>(); const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({ const schema = z.object({
resolver: zodResolver( name: z
z.object({ .string()
name: z .trim()
.string() .refine(
.trim() value =>
.refine( value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
(value) => { error: validationMessage("bank.validation.category") },
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, )
{ error: validationMessage("bank.validation.category") }, .refine(
) value =>
.refine( !categories.data?.some(
(value) => c =>
!categories.data?.some( c.id !== editing?.id
(c) => && c.name.toLocaleLowerCase("ru-RU")
c.id !== editing?.id && === value.toLocaleLowerCase("ru-RU"),
c.name.toLocaleLowerCase("ru-RU") ===
value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
), ),
}), { error: validationMessage("bank.validation.duplicate") },
), ),
defaultValues: { name: "" },
}); });
const pending = !opened || methods.formState.isSubmitting; const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
const isPending = !opened || methods.formState.isSubmitting;
return ( return (
<Modal <Modal
opened={opened} closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onExitTransitionEnd={props.onClose} onExitTransitionEnd={props.onClose}
onClose={() => !pending && setOpened(false)} opened={opened}
title={t("bank.categories")} title={t("bank.categories")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
@@ -107,8 +105,8 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
try { try {
if (editing) if (editing)
await mutation.updateCategory.mutateAsync({ await mutation.updateCategory.mutateAsync({
id: editing.id,
body: { name: values.name, version: editing.version }, body: { name: values.name, version: editing.version },
id: editing.id,
}); });
else { else {
const result = await mutation.createCategory.mutateAsync({ const result = await mutation.createCategory.mutateAsync({
@@ -116,24 +114,25 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
}); });
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSession.current++; editSessionRef.current++;
setEditing(undefined); setEditing(undefined);
setLatest(undefined); setLatest(undefined);
methods.reset({ name: "" }); methods.reset({ name: "" });
} catch (failure) { }
setError(failure); catch (error_) {
setError(error_);
} }
})(event); })(event);
}} }}
> >
<fieldset disabled={pending} className={classes.fieldset}> <fieldset className={classes.fieldset} disabled={isPending}>
<Stack> <Stack>
{categories.data?.map((category) => ( {categories.data?.map(category => (
<Group justify="space-between" key={category.id}> <Group justify="space-between" key={category.id}>
<Group gap="xs"> <Group gap="xs">
<BankOperationIcon <BankOperationIcon
kind="expense"
categoryName={category.name} categoryName={category.name}
kind="expense"
/> />
<Text>{category.name}</Text> <Text>{category.name}</Text>
{category.isDefault && ( {category.isDefault && (
@@ -141,14 +140,14 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)} )}
</Group> </Group>
<Button <Button
variant="subtle"
onClick={() => { onClick={() => {
editSession.current++; editSessionRef.current++;
setEditing(category); setEditing(category);
methods.reset({ name: category.name }); methods.reset({ name: category.name });
setError(undefined); setError(undefined);
setLatest(undefined); setLatest(undefined);
}} }}
variant="subtle"
> >
{t("bank.rename")} {t("bank.rename")}
</Button> </Button>
@@ -159,35 +158,35 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)} )}
{categories.isError && ( {categories.isError && (
<BankFeedback <BankFeedback
loadingError
error={categories.error}
cached={Boolean(categories.data)} cached={Boolean(categories.data)}
error={categories.error}
loadingError
retry={() => void categories.refetch()} retry={() => void categories.refetch()}
/> />
)} )}
<CategoryFields /> <CategoryFields />
{error ? ( {!!(error) && ((
<> <>
<BankFeedback error={error} /> <BankFeedback error={error} />
{editing && ( {editing && (
<Button <Button
variant="default"
onClick={async () => { onClick={async () => {
const session = editSession.current; const session = editSessionRef.current;
const response = await categories.refetch(); const response = await categories.refetch();
if (session !== editSession.current) return; if (session !== editSessionRef.current) return;
if (response.error) setError(response.error); if (response.error) setError(response.error);
else else
setLatest( setLatest(
response.data?.find((c) => c.id === editing.id), response.data?.find(c => c.id === editing.id),
); );
}} }}
variant="default"
> >
{t("bank.reloadRecord")} {t("bank.reloadRecord")}
</Button> </Button>
)} )}
</> </>
) : null} ))}
{latest && latest.id === editing?.id && ( {latest && latest.id === editing?.id && (
<> <>
<Text>{t("bank.versionHint", { details: latest.name })}</Text> <Text>{t("bank.versionHint", { details: latest.name })}</Text>
@@ -203,24 +202,24 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
</> </>
)} )}
<Group> <Group>
<Button type="submit" loading={pending}> <Button loading={isPending} type="submit">
{t(editing ? "bank.save" : "bank.addCategory")} {t(editing ? "bank.save" : "bank.addCategory")}
</Button> </Button>
{editing && ( {editing && (
<Button <Button
variant="default"
onClick={() => { onClick={() => {
editSession.current++; editSessionRef.current++;
setEditing(undefined); setEditing(undefined);
setLatest(undefined); setLatest(undefined);
methods.reset({ name: "" }); methods.reset({ name: "" });
setError(undefined); setError(undefined);
}} }}
variant="default"
> >
{t("bank.cancel")} {t("bank.cancel")}
</Button> </Button>
)} )}
<Button variant="default" onClick={() => setOpened(false)}> <Button onClick={() => setOpened(false)} variant="default">
{t("bank.close")} {t("bank.close")}
</Button> </Button>
</Group> </Group>
+44 -42
View File
@@ -1,5 +1,7 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const MIN_BOND_SEARCH_LENGTH = 2;
type BondPeriod = NonNullable< type BondPeriod = NonNullable<
NonNullable< NonNullable<
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
@@ -8,15 +10,6 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000; const BOND_STALE_TIME_MS = 30_000;
function useBondSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } },
}),
enabled: query.length >= 2,
staleTime: BOND_STALE_TIME_MS,
});
}
function bondQueryOptions(uid: string) { function bondQueryOptions(uid: string) {
return { return {
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
@@ -25,10 +18,9 @@ function bondQueryOptions(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}; };
} }
function useBond(identifier: string) { function useBond(identifier: string) {
const isUid = const isUid
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier, identifier,
); );
@@ -45,24 +37,7 @@ function useBond(identifier: string) {
}); });
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
function useBondMetrics(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondQuote(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondCandles(uid: string, period: BondPeriod) { function useBondCandles(uid: string, period: BondPeriod) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
@@ -72,16 +47,6 @@ function useBondCandles(uid: string, period: BondPeriod) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondPosition(uid: string) {
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondCoupons(uid: string) { function useBondCoupons(uid: string) {
return useResourceQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
@@ -92,9 +57,46 @@ function useBondCoupons(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondMetrics(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondPosition(uid: string) {
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondQuote(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } },
}),
enabled: query.length >= MIN_BOND_SEARCH_LENGTH,
staleTime: BOND_STALE_TIME_MS,
});
}
export { export { type BondPeriod,
type BondPeriod, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
useBondCoupons, useBondCoupons,
+2 -2
View File
@@ -1,5 +1,5 @@
export { export { type BondPeriod,
type BondPeriod, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
useBondCoupons, useBondCoupons,
@@ -1,9 +1,11 @@
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { import {
brokerageOperationsQueryParams, brokerageOperationsQueryParams as brokerageOperationsQueryParameters,
useBrokerageOperations, useBrokerageOperations,
} from "./useBrokerageOperations"; } from "./useBrokerageOperations";
@@ -11,11 +13,11 @@ describe("useBrokerageOperations", () => {
it("normalizes categories, preserves the cursor and loads a page", async () => { it("normalizes categories, preserves the cursor and loads a page", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
expect( expect(
brokerageOperationsQueryParams({ brokerageOperationsQueryParameters({
categories: ["tax", "income"], categories: ["tax", "income"],
cursor: "next", cursor: "next",
}), }),
@@ -1,16 +1,16 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
type BrokerageOperation = NonNullable< type BrokerageOperation = NonNullable<
OperationsEndpoint["responses"][200]["content"]["application/json"]["data"] OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
>["items"][number]; >["items"][number];
type BrokerageOperationsQuery = Omit< type BrokerageOperationsQuery = Omit<
NonNullable<OperationsEndpoint["parameters"]["query"]>, NonNullable<OperationsEndpoint["parameters"]["query"]>,
"sync" "sync"
>; >;
type OperationCategory = BrokerageOperation["category"]; type OperationCategory = BrokerageOperation["category"];
type OperationsEndpoint
= paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
const operationCategories = [ const operationCategories = [
"trade", "trade",
@@ -21,19 +21,6 @@ const operationCategories = [
"other", "other",
] as const satisfies readonly OperationCategory[]; ] as const satisfies readonly OperationCategory[];
function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
return {
...(query.instrumentUid ? { instrumentUid: query.instrumentUid } : {}),
...(query.from ? { from: query.from } : {}),
...(query.to ? { to: query.to } : {}),
...(query.categories?.length
? { categories: [...query.categories].sort() }
: {}),
...(query.cursor ? { cursor: query.cursor } : {}),
...(query.limit ? { limit: query.limit } : {}),
};
}
function brokerageOperationsQueryOptions( function brokerageOperationsQueryOptions(
accountId: string | undefined, accountId: string | undefined,
query: BrokerageOperationsQuery = {}, query: BrokerageOperationsQuery = {},
@@ -44,12 +31,23 @@ function brokerageOperationsQueryOptions(
{ {
params: { params: {
path: { accountId: accountId ?? "" }, path: { accountId: accountId ?? "" },
query: brokerageOperationsQueryParams(query), query: brokerageOperationsQueryParameters(query),
}, },
}, },
); );
} }
function brokerageOperationsQueryParameters(query: BrokerageOperationsQuery) {
return {
...(query.instrumentUid && { instrumentUid: query.instrumentUid }),
...(query.from && { from: query.from }),
...(query.to && { to: query.to }),
...(query.categories?.length && { categories: query.categories.toSorted((left, right) => left.localeCompare(right)) }),
...(query.cursor && { cursor: query.cursor }),
...(query.limit && { limit: query.limit }),
};
}
function useBrokerageOperations( function useBrokerageOperations(
accountId: string | undefined, accountId: string | undefined,
query: BrokerageOperationsQuery = {}, query: BrokerageOperationsQuery = {},
@@ -65,8 +63,8 @@ function useBrokerageOperations(
export { export {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
brokerageOperationsQueryParams, brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
type OperationCategory,
operationCategories, operationCategories,
type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
}; };
+1 -1
View File
@@ -1,8 +1,8 @@
export { export {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories, operationCategories,
type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
+40 -41
View File
@@ -1,10 +1,11 @@
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
type PaymentPresentation = { type PaymentPresentation = {
amount: string; amount: string;
label: string; label: string;
tone: "positive" | "negative" | "neutral"; tone: "negative" | "neutral" | "positive";
}; };
function entryLabelKey(operation: BrokerageOperation): string { function entryLabelKey(operation: BrokerageOperation): string {
@@ -15,6 +16,44 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isZeroDecimal(value: string): boolean {
return /^-?0$|^-?0\.0+$/.test(value);
}
function operationLabelKey(type: string): string {
if (
[
"OPERATION_TYPE_BUY",
"OPERATION_TYPE_BUY_CARD",
"OPERATION_TYPE_BUY_MARGIN",
].includes(type)
)
return "ledger.operationType.buy";
if (
[
"OPERATION_TYPE_SELL",
"OPERATION_TYPE_SELL_CARD",
"OPERATION_TYPE_SELL_MARGIN",
].includes(type)
)
return "ledger.operationType.sell";
if (
[
"OPERATION_TYPE_DIV_EXT",
"OPERATION_TYPE_DIVIDEND",
"OPERATION_TYPE_DIVIDEND_TRANSFER",
].includes(type)
)
return "ledger.operationType.dividend";
if (type.includes("TAX")) return "ledger.operationType.tax";
if (type.includes("FEE")) return "ledger.operationType.fee";
if (type === "OPERATION_TYPE_INPUT_SECURITIES")
return "ledger.operationType.input";
if (type === "OPERATION_TYPE_OUTPUT_SECURITIES")
return "ledger.operationType.output";
return type.startsWith("OPERATION_TYPE_TRANS_") ? "ledger.operationType.transfer" : "ledger.operationType.other";
}
function paymentPresentation( function paymentPresentation(
operation: BrokerageOperation, operation: BrokerageOperation,
): PaymentPresentation { ): PaymentPresentation {
@@ -46,44 +85,4 @@ function paymentPresentation(
}; };
} }
function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
function operationLabelKey(type: string): string {
if (
[
"OPERATION_TYPE_BUY",
"OPERATION_TYPE_BUY_CARD",
"OPERATION_TYPE_BUY_MARGIN",
].includes(type)
)
return "ledger.operationType.buy";
if (
[
"OPERATION_TYPE_SELL",
"OPERATION_TYPE_SELL_CARD",
"OPERATION_TYPE_SELL_MARGIN",
].includes(type)
)
return "ledger.operationType.sell";
if (
[
"OPERATION_TYPE_DIVIDEND",
"OPERATION_TYPE_DIV_EXT",
"OPERATION_TYPE_DIVIDEND_TRANSFER",
].includes(type)
)
return "ledger.operationType.dividend";
if (type.includes("TAX")) return "ledger.operationType.tax";
if (type.includes("FEE")) return "ledger.operationType.fee";
if (type === "OPERATION_TYPE_INPUT_SECURITIES")
return "ledger.operationType.input";
if (type === "OPERATION_TYPE_OUTPUT_SECURITIES")
return "ledger.operationType.output";
if (type.startsWith("OPERATION_TYPE_TRANS_"))
return "ledger.operationType.transfer";
return "ledger.operationType.other";
}
export { operationLabelKey, paymentPresentation }; export { operationLabelKey, paymentPresentation };
@@ -1,15 +1,19 @@
import type { FC } from "react";
import { Anchor, Group, Text } from "@mantine/core"; import { Anchor, Group, Text } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui"; import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { import {
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "@/shared/lib"; } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display"; import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
@@ -20,55 +24,57 @@ type LedgerEntryRowProps = {
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => { const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
const { operation } = props; const { operation } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const instrument = const instrument
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); = operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
const payment = paymentPresentation(operation); const payment = paymentPresentation(operation);
const metadata = [ const metadata = [
t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }), t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }),
instrument, instrument,
props.withDetails ? null : operation.account?.name, props.withDetails ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : operation.account?.name,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
return ( return (
<EntryCard <EntryCard
title={operation.description || t(operationLabelKey(operation.type))}
metadata={metadata}
details={ details={
props.withDetails ? ( (props.withDetails)
<> ? ((
<Group gap="sm"> <>
{operation.quantity != null ? ( <Group gap="sm">
<Text size="xs" c="dimmed"> {operation.quantity != undefined && ((
{t("ledger.quantity", { <Text c="dimmed" size="xs">
value: formatDecimal(operation.quantity), {t("ledger.quantity", {
})} value: formatDecimal(operation.quantity),
</Text> })}
) : null} </Text>
{operation.price ? ( ))}
<Text size="xs" c="dimmed"> {!!(operation.price) && ((
{t("ledger.price", { value: formatMoney(operation.price) })} <Text c="dimmed" size="xs">
</Text> {t("ledger.price", { value: formatMoney(operation.price) })}
) : null} </Text>
</Group> ))}
{operation.account ? ( </Group>
<Anchor {!!(operation.account) && ((
component={Link} <Anchor
size="sm" component={Link}
to={`/accounts/${encodeURIComponent(operation.account.id)}`} size="sm"
> to={`/accounts/${encodeURIComponent(operation.account.id)}`}
{operation.account.name} >
</Anchor> {operation.account.name}
) : null} </Anchor>
</> ))}
) : undefined </>
))
: undefined
} }
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
imageSrc={instrumentLogoUrl(operation.logoName)} imageSrc={instrumentLogoUrl(operation.logoName)}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
metadata={metadata}
title={operation.description || t(operationLabelKey(operation.type))}
tone={payment.tone}
value={payment.amount} value={payment.amount}
valueLabel={t(payment.label)} valueLabel={t(payment.label)}
tone={payment.tone}
/> />
); );
}; };
@@ -1,18 +1,20 @@
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts"; import { useBrokerageAccounts } from "./useBrokerageAccounts";
describe("useBrokerageAccounts", () => { describe("useBrokerageAccounts", () => {
it("loads accounts through the authenticated query seam", async () => { it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
@@ -21,8 +23,8 @@ describe("useBrokerageAccounts", () => {
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE",
}, },
]), ]),
), ),
@@ -38,8 +40,8 @@ describe("useBrokerageAccounts", () => {
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE",
}, },
]); ]);
}); });
@@ -47,8 +49,8 @@ describe("useBrokerageAccounts", () => {
it("keeps the current connection snapshot after a background refetch error", async () => { it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
let calls = 0; let calls = 0;
server.use( server.use(
@@ -60,8 +62,8 @@ describe("useBrokerageAccounts", () => {
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE",
}, },
]), ]),
) )
+19 -17
View File
@@ -3,24 +3,26 @@ import type {
useBrokerageAccounts, useBrokerageAccounts,
} from "../api/useBrokerageAccounts"; } from "../api/useBrokerageAccounts";
type Money = NonNullable< const VISIBLE_CURRENCY_LIMIT = 2;
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type BrokerageAccountCard = { type BrokerageAccountCard = {
account: BrokerageAccount;
positionCounts: null | { bonds: number; stocks: number };
source?: NonNullable< source?: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"] ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined >["dependencies"] extends (infer T)[] | undefined
? T ? T
: never; : never;
account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null;
summary: summary:
| { state: "unavailable" } | { remainingCurrencyCount: number; state: "available"; totals: Money[] }
| { state: "empty" } | { state: "empty" }
| { state: "available"; totals: Money[]; remainingCurrencyCount: number }; | { state: "unavailable" };
}; };
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const; return `accounts.types.${type}` as const;
} }
@@ -33,34 +35,34 @@ function brokerageAccountCards(
): BrokerageAccountCard[] { ): BrokerageAccountCard[] {
return accounts.map((account) => { return accounts.map((account) => {
const source = dependencies.find( const source = dependencies.find(
(item) => item =>
item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`, item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
); );
const summary = account.portfolioSummary; const summary = account.portfolioSummary;
if (!summary) if (!summary)
return { return {
source,
account, account,
positionCounts: null, positionCounts: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null,
source,
summary: { state: "unavailable" }, summary: { state: "unavailable" },
}; };
const positionCounts = { const positionCounts = {
stocks: summary.stockPositionCount,
bonds: summary.bondPositionCount, bonds: summary.bondPositionCount,
stocks: summary.stockPositionCount,
}; };
if (!summary.totalsByCurrency.length) if (summary.totalsByCurrency.length === 0)
return { source, account, positionCounts, summary: { state: "empty" } }; return { account, positionCounts, source, summary: { state: "empty" } };
return { return {
source,
account, account,
positionCounts, positionCounts,
source,
summary: { summary: {
state: "available",
totals: summary.totalsByCurrency.slice(0, 2),
remainingCurrencyCount: Math.max( remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - 2, summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0, 0,
), ),
state: "available",
totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT),
}, },
}; };
}); });
@@ -1,34 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
const syntheticAccount = { const syntheticAccount = {
id: "storybook-account", id: "storybook-account",
name: "Учебный брокерский счёт", name: "Учебный брокерский счёт",
type: "BROKERAGE",
openedOn: "2025-03-15", openedOn: "2025-03-15",
portfolioSummary: { portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12, bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [ totalsByCurrency: [
{ currency: "RUB", value: "125000.50" }, { currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" }, { currency: "USD", value: "320" },
{ currency: "EUR", value: "210" }, { currency: "EUR", value: "210" },
], ],
}, },
type: "BROKERAGE",
} satisfies BrokerageAccount; } satisfies BrokerageAccount;
const meta = { const meta = {
title: "Product/BrokerageAccountList", args: { accounts: brokerageAccountCards([syntheticAccount]) },
component: BrokerageAccountList,
tags: ["autodocs"],
argTypes: { argTypes: {
accounts: { control: false }, accounts: { control: false },
}, },
component: BrokerageAccountList,
decorators: [ decorators: [
(Story) => ( Story => (
<MemoryRouter initialEntries={["/accounts"]}> <MemoryRouter initialEntries={["/accounts"]}>
<Story /> <Story />
</MemoryRouter> </MemoryRouter>
@@ -42,7 +44,8 @@ const meta = {
}, },
}, },
}, },
args: { accounts: brokerageAccountCards([syntheticAccount]) }, tags: ["autodocs"],
title: "Product/BrokerageAccountList",
} satisfies Meta<typeof BrokerageAccountList>; } satisfies Meta<typeof BrokerageAccountList>;
export default meta; export default meta;
@@ -61,37 +64,39 @@ export const Available: Story = {
}, },
}; };
export const Unavailable: Story = { export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
name: "Сводка недоступна", {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ ...syntheticAccount, portfolioSummary: null, openedOn: null }, { ...syntheticAccount, openedOn: null, portfolioSummary: null },
]), ]),
}, },
parameters: { name: "Сводка недоступна",
docs: { parameters: {
description: { docs: {
story: description: {
story:
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
},
}, },
}, },
}, }
}; /* eslint-enable unicorn/no-null */);
export const Empty: Story = { export const Empty: Story = {
name: "Нет текущих сумм",
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ {
...syntheticAccount, ...syntheticAccount,
portfolioSummary: { portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0, bondPositionCount: 0,
stockPositionCount: 0,
totalsByCurrency: [],
}, },
}, },
]), ]),
}, },
name: "Нет текущих сумм",
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -103,7 +108,6 @@ export const Empty: Story = {
}; };
export const LongName: Story = { export const LongName: Story = {
name: "Длинное название счёта",
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ {
@@ -113,6 +117,7 @@ export const LongName: Story = {
}, },
]), ]),
}, },
name: "Длинное название счёта",
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -124,105 +129,107 @@ export const LongName: Story = {
}; };
export const AccountGrid: Story = { export const AccountGrid: Story = {
name: "Несколько счетов",
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ {
...syntheticAccount, ...syntheticAccount,
name: "Основной", name: "Основной",
portfolioSummary: { portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
stockPositionCount: 8,
bondPositionCount: 14, bondPositionCount: 14,
stockPositionCount: 8,
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
}, },
}, },
{ {
...syntheticAccount, ...syntheticAccount,
id: "storybook-iis", id: "storybook-iis",
name: "Долгосрочные инвестиции", name: "Долгосрочные инвестиции",
type: "INDIVIDUAL_INVESTMENT",
portfolioSummary: { portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
stockPositionCount: 0,
bondPositionCount: 6, bondPositionCount: 6,
stockPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
}, },
type: "INDIVIDUAL_INVESTMENT",
}, },
]), ]),
}, },
name: "Несколько счетов",
}; };
export const UnvaluedPositions: Story = { export const UnvaluedPositions: Story = {
name: "Бумаги без текущей оценки",
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ {
...syntheticAccount, ...syntheticAccount,
portfolioSummary: { portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 1, bondPositionCount: 1,
stockPositionCount: 2,
totalsByCurrency: [],
}, },
}, },
]), ]),
}, },
name: "Бумаги без текущей оценки",
}; };
export const LargeValues: Story = { export const LargeValues: Story = {
name: "Длинные суммы",
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ {
...syntheticAccount, ...syntheticAccount,
portfolioSummary: { portfolioSummary: {
bondPositionCount: 105,
stockPositionCount: 21,
totalsByCurrency: [ totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" }, { currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" }, { currency: "USD", value: "-9876543210987654321.50" },
], ],
stockPositionCount: 21,
bondPositionCount: 105,
}, },
}, },
]), ]),
}, },
name: "Длинные суммы",
}; };
export const IndependentFreshness: Story = { export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
name: "Независимая свежесть и ошибка сводок", {
args: { args: {
accounts: brokerageAccountCards( accounts: brokerageAccountCards(
[ [
syntheticAccount, syntheticAccount,
{ {
...syntheticAccount, ...syntheticAccount,
id: "waiting", id: "waiting",
name: "Новый счёт", name: "Новый счёт",
portfolioSummary: null, portfolioSummary: null,
},
],
[
{
resourceKey: "portfolio/storybook-account",
dataRevision: "1",
stateVersion: "failed",
availability: "complete",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
sync: {
id: "failed",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
}, },
}, ],
{ [
resourceKey: "portfolio/waiting", {
dataRevision: "0", availability: "complete",
stateVersion: "waiting", dataRevision: "1",
availability: "missing", freshness: "stale",
freshness: "unknown", lastSuccessfulAt: "2026-09-24T10:30:00Z",
lastSuccessfulAt: null, resourceKey: "portfolio/storybook-account",
sync: { id: "waiting", state: "running", errorCode: null }, stateVersion: "failed",
}, sync: {
], errorCode: "TBANK_UNAVAILABLE",
), id: "failed",
}, state: "failed",
}; },
},
{
availability: "missing",
dataRevision: "0",
freshness: "unknown",
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
stateVersion: "waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
),
},
name: "Независимая свежесть и ошибка сводок",
}
/* eslint-enable unicorn/no-null */);
@@ -2,7 +2,9 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
@@ -16,36 +18,40 @@ describe("BrokerageAccountList", () => {
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
portfolioSummary: { portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12, bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [ totalsByCurrency: [
{ currency: "RUB", value: "1500" }, { currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" }, { currency: "USD", value: "20" },
{ currency: "EUR", value: "5" }, { currency: "EUR", value: "5" },
], ],
}, },
},
{
id: "account-2",
name: "Недоступный",
type: "BROKERAGE", type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
},
{
id: "account-3",
name: "Пустой",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 0,
},
}, },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
id: "account-2",
name: "Недоступный",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
id: "account-3",
name: "Пустой",
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 2,
totalsByCurrency: [],
},
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */),
])} ])}
/> />
</MemoryRouter> </MemoryRouter>
@@ -65,18 +71,18 @@ describe("BrokerageAccountList", () => {
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3); expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ })); const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getByText("Акции").nextElementSibling).toHaveTextContent( expect(available.getAllByRole("definition")[0]).toHaveTextContent(
"5", "5",
); );
expect( expect(
available.getByText("Облигации").nextElementSibling, available.getAllByRole("definition")[1],
).toHaveTextContent("12"); ).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getByText("Акции").nextElementSibling).toHaveTextContent( expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent(
"2", "2",
); );
expect( expect(
unvalued.getByText("Облигации").nextElementSibling, unvalued.getAllByRole("definition")[1],
).toHaveTextContent("0"); ).toHaveTextContent("0");
expect( expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
@@ -88,21 +94,23 @@ describe("BrokerageAccountList", () => {
it("shows portfolio summary freshness independently for each account", () => { it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards( const accounts = brokerageAccountCards(
["fresh", "stale"].map((id) => ({ ["fresh", "stale"].map(id => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id, {
name: id, id,
type: "BROKERAGE" as const, name: id,
openedOn: null, openedOn: null,
portfolioSummary: { portfolioSummary: {
stockPositionCount: 1, bondPositionCount: 0,
bondPositionCount: 0, stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }], totalsByCurrency: [{ currency: "RUB", value: "10" }],
}, },
})), type: "BROKERAGE" as const,
}
/* eslint-enable unicorn/no-null */))),
[ [
resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" }) resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
.meta, .meta,
resource(null, { resourceKey: "portfolio/stale", freshness: "stale" }) resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
.meta, .meta,
], ],
); );
@@ -1,9 +1,13 @@
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display"; import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css"; import classes from "./BrokerageAccountList.module.css";
@@ -31,18 +35,18 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
return ( return (
<Stack className={classes.summary} gap={4}> <Stack className={classes.summary} gap={4}>
{summary.totals.map((total) => ( {summary.totals.map(total => (
<Text className={classes.total} fw={700} key={total.currency}> <Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)} {formatMoney(total)}
</Text> </Text>
))} ))}
{summary.remainingCurrencyCount ? ( {!!(summary.remainingCurrencyCount) && ((
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", { {t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount, count: summary.remainingCurrencyCount,
})} })}
</Text> </Text>
) : null} ))}
</Stack> </Stack>
); );
}; };
@@ -53,37 +57,37 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
return ( return (
<SimpleGrid className={classes.list} cols={1} spacing="lg"> <SimpleGrid className={classes.list} cols={1} spacing="lg">
{accounts.map(({ account, summary, positionCounts, source }) => ( {accounts.map(({ account, positionCounts, source, summary }) => (
<Card <Card
component={Link}
to={`/accounts/${account.id}`}
key={account.id}
className={classes.card} className={classes.card}
component={Link}
key={account.id}
padding="xl" padding="xl"
radius="lg" radius="lg"
to={`/accounts/${account.id}`}
withBorder withBorder
> >
<Stack className={classes.identity} gap="sm"> <Stack className={classes.identity} gap="sm">
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} size="xs" fw={700}> <Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))} {t(accountTypeKey(account.type))}
</Text> </Text>
<svg <svg
aria-hidden="true"
className={classes.accountIcon} className={classes.accountIcon}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
> >
<rect x="3" y="5" width="18" height="15" rx="3" /> <rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" /> <path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg> </svg>
</Group> </Group>
<Text component="h2" className={classes.name} fw={700}> <Text className={classes.name} component="h2" fw={700}>
{account.name} {account.name}
</Text> </Text>
</Stack> </Stack>
@@ -92,27 +96,27 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
{t("accounts.currentValue")} {t("accounts.currentValue")}
</Text> </Text>
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
{source?.lastSuccessfulAt ? ( {!!(source?.lastSuccessfulAt) && ((
<Text size="xs" c="dimmed"> <Text c="dimmed" size="xs">
{t("investment.sourceTime", { {t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt), date: formatSnapshotTime(source.lastSuccessfulAt),
})} })}
</Text> </Text>
) : null} ))}
{source?.sync.state === "running" || {(source?.sync.state === "running"
source?.sync.state === "queued" ? ( || source?.sync.state === "queued") && ((
<Text size="xs" role="status"> <Text role="status" size="xs">
{t("accounts.refreshing")} {t("accounts.refreshing")}
</Text> </Text>
) : null} ))}
{source?.sync.state === "failed" ? ( {(source?.sync.state === "failed") && ((
<Text size="xs">{t("accounts.refreshError")}</Text> <Text size="xs">{t("accounts.refreshError")}</Text>
) : null} ))}
{source?.freshness === "stale" ? ( {(source?.freshness === "stale") && ((
<Text size="xs">{t("investment.stale")}</Text> <Text size="xs">{t("investment.stale")}</Text>
) : null} ))}
</Stack> </Stack>
{positionCounts ? ( {!!(positionCounts) && ((
<dl className={classes.positions}> <dl className={classes.positions}>
<div className={classes.stocks}> <div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt> <dt>{t("accounts.stocks")}</dt>
@@ -127,18 +131,18 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
</dd> </dd>
</div> </div>
</dl> </dl>
) : null} ))}
<Group className={classes.footer} justify="space-between" gap="sm"> <Group className={classes.footer} gap="sm" justify="space-between">
{account.openedOn ? ( {!!(account.openedOn) && ((
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("accounts.openedOn", { {t("accounts.openedOn", {
date: formatDate(account.openedOn), date: formatDate(account.openedOn),
})} })}
</Text> </Text>
) : null} ))}
<Text component="span" className={classes.open} size="sm" fw={600}> <Text className={classes.open} component="span" fw={600} size="sm">
{t("accounts.openAccount")} {t("accounts.openAccount")}
<span className={classes.arrow} aria-hidden="true"> <span aria-hidden="true" className={classes.arrow}>
→ →
</span> </span>
</Text> </Text>
@@ -1,18 +1,20 @@
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents"; import { useBrokerageEvents } from "./useBrokerageEvents";
describe("useBrokerageEvents", () => { describe("useBrokerageEvents", () => {
it("does not fetch events without an account ID", async () => { it("does not fetch events without an account ID", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
const requested = vi.fn(() => HttpResponse.json({ events: [] })); const requested = vi.fn(() => HttpResponse.json({ events: [] }));
server.use( server.use(
@@ -27,29 +29,31 @@ describe("useBrokerageEvents", () => {
it("loads future cash events through the authenticated query seam", async () => { it("loads future cash events through the authenticated query seam", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/events", () => http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
HttpResponse.json( HttpResponse.json(
resource({ resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
accountId: "account-1", {
calculatedAt: "2026-08-14T00:00:00Z", accountId: "account-1",
events: [ calculatedAt: "2026-08-14T00:00:00Z",
{ events: [
type: "dividend", {
date: "2026-09-01", date: "2026-09-01",
dateKind: "registry", dateKind: "registry",
figi: "figi-1", displayName: "Сбер",
instrumentUid: null, figi: "figi-1",
ticker: "SBER", instrumentUid: null,
displayName: "Сбер", logoName: "sber3.png",
logoName: "sber3.png", projectedAmount: { currency: "RUB", value: "12.34" },
projectedAmount: { currency: "RUB", value: "12.34" }, ticker: "SBER",
}, type: "dividend",
], },
}), ],
}
/* eslint-enable unicorn/no-null */)),
), ),
), ),
); );
@@ -60,17 +64,19 @@ describe("useBrokerageEvents", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.events).toEqual([ expect(result.current.data?.events).toEqual([
{ (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
type: "dividend", {
date: "2026-09-01", date: "2026-09-01",
dateKind: "registry", dateKind: "registry",
figi: "figi-1", displayName: "Сбер",
instrumentUid: null, figi: "figi-1",
ticker: "SBER", instrumentUid: null,
displayName: "Сбер", logoName: "sber3.png",
logoName: "sber3.png", projectedAmount: { currency: "RUB", value: "12.34" },
projectedAmount: { currency: "RUB", value: "12.34" }, ticker: "SBER",
}, type: "dividend",
}
/* eslint-enable unicorn/no-null */),
]); ]);
}); });
}); });
@@ -1,10 +1,10 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
type BrokerageEvent = NonNullable< type BrokerageEvent = NonNullable<
EventsEndpoint["responses"][200]["content"]["application/json"]["data"] EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
>["events"][number]; >["events"][number];
type EventsEndpoint
= paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
function useBrokerageEvents( function useBrokerageEvents(
accountId: string | undefined, accountId: string | undefined,
@@ -1,7 +1,10 @@
import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react"; import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
type BrokerageEventCardProps = { event: BrokerageEvent }; type BrokerageEventCardProps = { event: BrokerageEvent };
@@ -14,7 +17,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const metadata = [ const metadata = [
date, date,
event.dateKind === "registry" ? t("events.dateKind.registry") : null, event.dateKind === "registry" ? t("events.dateKind.registry") : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
event.isin, event.isin,
event.account?.name, event.account?.name,
] ]
@@ -23,19 +26,19 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
return ( return (
<EntryCard <EntryCard
title={`${t(`events.type.${event.type}`)} · ${event.displayName}`} imageSrc={instrumentLogoUrl(event.logoName)}
metadata={metadata}
initial={( initial={(
(event.ticker ?? event.displayName).trim()[0] ?? "?" (event.ticker ?? event.displayName).trim()[0] ?? "?"
).toUpperCase()} ).toUpperCase()}
imageSrc={instrumentLogoUrl(event.logoName)} metadata={metadata}
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral"
value={ value={
event.projectedAmount event.projectedAmount
? formatMoney(event.projectedAmount) ? formatMoney(event.projectedAmount)
: t("events.noAmount") : t("events.noAmount")
} }
valueLabel={t("events.projectedAmount")} valueLabel={t("events.projectedAmount")}
tone="neutral"
/> />
); );
}; };
@@ -1,32 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventsPreview } from "./BrokerageEventsPreview"; import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
const syntheticEvent = { const syntheticEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
type: "coupon", {
date: "2026-10-15", date: "2026-10-15",
dateKind: "event", dateKind: "event",
displayName: "Учебные облигации",
figi: null, figi: null,
instrumentUid: null, instrumentUid: null,
ticker: "DEMO",
displayName: "Учебные облигации",
logoName: null,
isin: "RU000A0JX0J2", isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "850.50" }, projectedAmount: { currency: "RUB", value: "850.50" },
} satisfies BrokerageEvent; ticker: "DEMO",
type: "coupon",
}
/* eslint-enable unicorn/no-null */) satisfies BrokerageEvent;
const eventsPath = "/accounts/storybook-account/events"; const eventsPath = "/accounts/storybook-account/events";
const meta = { const meta = {
title: "Product/RecentEvents", args: {
component: BrokerageEventsPreview, events: {
tags: ["autodocs"], items: [
syntheticEvent,
{
...syntheticEvent,
date: "2026-11-01",
dateKind: "registry",
displayName: "Учебные технологии",
projectedAmount: { currency: "RUB", value: "1200" },
ticker: "EXAMPLE",
type: "dividend",
},
],
state: "loaded",
to: eventsPath,
},
},
argTypes: { argTypes: {
events: { control: false }, events: { control: false },
}, },
component: BrokerageEventsPreview,
decorators: [ decorators: [
(Story) => ( Story => (
<MemoryRouter initialEntries={["/accounts/storybook-account"]}> <MemoryRouter initialEntries={["/accounts/storybook-account"]}>
<Story /> <Story />
</MemoryRouter> </MemoryRouter>
@@ -40,24 +61,8 @@ const meta = {
}, },
}, },
}, },
args: { tags: ["autodocs"],
events: { title: "Product/RecentEvents",
state: "loaded",
to: eventsPath,
items: [
syntheticEvent,
{
...syntheticEvent,
type: "dividend",
date: "2026-11-01",
dateKind: "registry",
ticker: "EXAMPLE",
displayName: "Учебные технологии",
projectedAmount: { currency: "RUB", value: "1200" },
},
],
},
},
} satisfies Meta<typeof BrokerageEventsPreview>; } satisfies Meta<typeof BrokerageEventsPreview>;
export default meta; export default meta;
@@ -77,8 +82,8 @@ export const Loaded: Story = {
}; };
export const Loading: Story = { export const Loading: Story = {
name: "Загрузка раздела",
args: { events: { state: "loading", to: eventsPath } }, args: { events: { state: "loading", to: eventsPath } },
name: "Загрузка раздела",
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -90,8 +95,8 @@ export const Loading: Story = {
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка получения событий",
args: { events: { state: "error", to: eventsPath } }, args: { events: { state: "error", to: eventsPath } },
name: "Ошибка получения событий",
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -103,8 +108,8 @@ export const Failed: Story = {
}; };
export const Empty: Story = { export const Empty: Story = {
name: "Нет ближайших событий",
args: { events: { state: "empty", to: eventsPath } }, args: { events: { state: "empty", to: eventsPath } },
name: "Нет ближайших событий",
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -115,40 +120,44 @@ export const Empty: Story = {
}, },
}; };
export const MissingAmount: Story = { export const MissingAmount: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
name: "Событие без ожидаемой суммы", {
args: { args: {
events: { events: {
state: "loaded", items: [{ ...syntheticEvent, projectedAmount: null }],
to: eventsPath, state: "loaded",
items: [{ ...syntheticEvent, projectedAmount: null }], to: eventsPath,
},
},
parameters: {
docs: {
description: {
story:
"Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
}, },
}, },
}, name: "Событие без ожидаемой суммы",
}; parameters: {
docs: {
export const LongEvent: Story = { description: {
name: "Длинное название на узком экране", story:
args: { "Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
events: {
state: "loaded",
to: eventsPath,
items: [
{
...syntheticEvent,
type: "offer",
displayName:
"Учебные облигации компании с очень длинным названием инструмента",
projectedAmount: null,
}, },
], },
}, },
}, }
}; /* eslint-enable unicorn/no-null */);
export const LongEvent: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
events: {
items: [
{
...syntheticEvent,
displayName:
"Учебные облигации компании с очень длинным названием инструмента",
projectedAmount: null,
type: "offer",
},
],
state: "loaded",
to: eventsPath,
},
},
name: "Длинное название на узком экране",
}
/* eslint-enable unicorn/no-null */);
@@ -1,26 +1,30 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
events: BrokerageEventsPreviewState; events: BrokerageEventsPreviewState;
}; };
type BrokerageEventsPreviewState = type BrokerageEventsPreviewState
| { state: "loading" | "error" | "empty"; to: string } = | {
| { items: BrokerageEvent[];
state: "loaded" | "partial"; lastSuccessfulAt?: null | string;
to: string; refreshError?: boolean;
lastSuccessfulAt?: string | null; stale?: boolean;
stale?: boolean; state: "loaded" | "partial";
refreshError?: boolean; to: string;
items: BrokerageEvent[]; }
}; | { state: "empty" | "error" | "loading"; to: string };
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => { const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props; const { events } = props;
@@ -28,53 +32,51 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" ? ( {(events.state === "loaded") && ((
<> <>
{events.lastSuccessfulAt ? ( {!!(events.lastSuccessfulAt) && ((
<Text size="xs" c="dimmed"> <Text c="dimmed" size="xs">
{t("investment.sourceTime", { {t("investment.sourceTime", {
date: formatSnapshotTime(events.lastSuccessfulAt), date: formatSnapshotTime(events.lastSuccessfulAt),
})} })}
</Text> </Text>
) : null} ))}
{events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null} {!!(events.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
{events.refreshError ? ( {!!(events.refreshError) && ((
<Text role="status">{t("accounts.refreshError")}</Text> <Text role="status">{t("accounts.refreshError")}</Text>
) : null} ))}
</> </>
) : null} ))}
{events.state === "loading" ? ( {(events.state === "loading") && ((
<LoadingSkeleton label={t("events.recentLoading")}> <LoadingSkeleton label={t("events.recentLoading")}>
{[0, 1, 2].map((index) => ( {["first", "second", "third"].map(placeholder => (
<Skeleton key={index} height={48} radius="sm" /> <Skeleton height={48} key={placeholder} radius="sm" />
))} ))}
</LoadingSkeleton> </LoadingSkeleton>
) : null} ))}
{events.state === "error" ? ( {(events.state === "error") && ((
<Alert color="red">{t("events.recentError")}</Alert> <Alert color="red">{t("events.recentError")}</Alert>
) : null} ))}
{events.state === "empty" ? ( {(events.state === "empty") && ((
<Text c="dimmed">{t("events.recentEmpty")}</Text> <Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null} ))}
{events.state === "partial" ? ( {(events.state === "partial") && ((
<Text role="status">{t("investment.partial")}</Text> <Text role="status">{t("investment.partial")}</Text>
) : null} ))}
{events.state === "loaded" || events.state === "partial" {(events.state === "loaded" || events.state === "partial") && (events.items.map(event => (
? events.items.map((event) => ( <BrokerageEventCard
<BrokerageEventCard event={event}
key={JSON.stringify([ key={JSON.stringify([
event.account?.id, event.account?.id,
event.type, event.type,
event.date, event.date,
event.instrumentUid, event.instrumentUid,
event.figi, event.figi,
event.ticker, event.ticker,
event.logoName, event.logoName,
])} ])}
event={event} />
/> )))}
))
: null}
<Button component={Link} to={events.to} variant="subtle"> <Button component={Link} to={events.to} variant="subtle">
{t("events.open")} {t("events.open")}
</Button> </Button>
+16 -16
View File
@@ -1,24 +1,11 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type IncomeAnalytics = NonNullable< type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeSummary = NonNullable<
function useIncomeSummary(accountId: string | undefined) { paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
return useResourceQuery({ >;
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-summary",
{ params: { path: { accountId: accountId ?? "" } } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
});
}
function useIncomeAnalytics( function useIncomeAnalytics(
accountId: string | undefined, accountId: string | undefined,
@@ -41,6 +28,19 @@ function useIncomeAnalytics(
}); });
} }
function useIncomeSummary(accountId: string | undefined) {
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-summary",
{ params: { path: { accountId: accountId ?? "" } } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
});
}
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeSummary, type IncomeSummary,
@@ -1,10 +1,12 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api"; import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { import {
type Portfolio, type Portfolio,
portfolioQueryOptions, portfolioQueryOptions,
@@ -12,25 +14,25 @@ import {
} from "./usePortfolio"; } from "./usePortfolio";
const snapshot: Portfolio = { const snapshot: Portfolio = {
positions: [],
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
cashBalances: [], cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
}; };
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "synthetic", accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
} }
describe("persistent resource query lifecycle", () => { describe("persistent resource query lifecycle", () => {
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => { it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
auth(); auth();
const modes: (string | null)[] = []; const modes: (null | string)[] = [];
let observations = 0; let observations = 0;
server.use( server.use(
http.get( http.get(
@@ -47,16 +49,17 @@ describe("persistent resource query lifecycle", () => {
}, },
), ),
); );
const { wrapper, queryClient } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePortfolio("a"), { wrapper }); const { result } = renderHook(() => usePortfolio("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new")); await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe", "observe"]); expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect( expect(
queryClient.getQueryData(portfolioQueryOptions("a").queryKey), queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot }); ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
}); });
it("does not convert missing or failed source data to empty financial success", async () => { it("does not convert missing or failed source data to empty financial success", async () => {
auth(); auth();
@@ -65,9 +68,9 @@ describe("persistent resource query lifecycle", () => {
HttpResponse.json( HttpResponse.json(
resource(null, { resource(null, {
sync: { sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "work", id: "work",
state: "failed", state: "failed",
errorCode: "TBANK_UNAVAILABLE",
}, },
}), }),
), ),
@@ -82,20 +85,22 @@ describe("persistent resource query lifecycle", () => {
expect(result.current.status).toBe("pending"); expect(result.current.status).toBe("pending");
expect(result.current.isPending).toBe(true); expect(result.current.isPending).toBe(true);
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
expect(result.current.transport).toEqual({ expect(result.current.transport).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
status: "success", {
fetchStatus: "idle", error: null,
error: null, fetchStatus: "idle",
}); status: "success",
}
/* eslint-enable unicorn/no-null */));
expect(result.current.meta?.availability).toBe("missing"); expect(result.current.meta?.availability).toBe("missing");
}); });
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth(); auth();
let ready = false; let isReady = false;
const refreshed = resource(snapshot, { stateVersion: "ready" }); const refreshed = resource(snapshot, { stateVersion: "ready" });
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(ready ? refreshed : resource(null)), HttpResponse.json(isReady ? refreshed : resource(null)),
), ),
); );
const { result } = renderHook(() => usePortfolio("a"), { const { result } = renderHook(() => usePortfolio("a"), {
@@ -109,7 +114,7 @@ describe("persistent resource query lifecycle", () => {
expect(missing.status).toBe("success"); expect(missing.status).toBe("success");
expect(missing.data).toEqual(resource(null)); expect(missing.data).toEqual(resource(null));
expect(missing.data?.data).toBeNull(); expect(missing.data?.data).toBeNull();
ready = true; isReady = true;
const loaded = await result.current.refetch(); const loaded = await result.current.refetch();
expect(loaded.data).toEqual(refreshed); expect(loaded.data).toEqual(refreshed);
expect(loaded.data?.data?.totalsByCurrency).toEqual( expect(loaded.data?.data?.totalsByCurrency).toEqual(
@@ -124,10 +129,8 @@ describe("persistent resource query lifecycle", () => {
}); });
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
auth(); auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => { const { promise: blocked, resolve: release } = Promise.withResolvers<void>();
release = resolve;
});
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio", "*/api/v1/brokerage-accounts/:accountId/portfolio",
@@ -151,19 +154,15 @@ describe("persistent resource query lifecycle", () => {
}); });
it("clears private snapshots on confirmed access loss and fences another pending success", async () => { it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth(); auth();
let denied = false; let isDenied = false;
let release!: () => void;
const blocked = new Promise<void>((resolve) => { const { promise: blocked, resolve: release } = Promise.withResolvers<void>();
release = resolve;
});
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio", "*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ params }) => { async ({ params }) => {
if (params.accountId === "b") await blocked; if (params.accountId === "b") await blocked;
if (params.accountId === "a" && denied) return isDenied && params.accountId === "a" ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) : HttpResponse.json(resource(snapshot));
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json(resource(snapshot));
}, },
), ),
); );
@@ -173,7 +172,7 @@ describe("persistent resource query lifecycle", () => {
{ wrapper }, { wrapper },
); );
await waitFor(() => expect(result.current.a.data).toEqual(snapshot)); await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
denied = true; isDenied = true;
await act(() => result.current.a.refetch()); await act(() => result.current.a.refetch());
await waitFor(() => expect(result.current.a.data).toBeUndefined()); await waitFor(() => expect(result.current.a.data).toBeUndefined());
await act(async () => { await act(async () => {
@@ -1,18 +1,20 @@
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio"; import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => { describe("usePortfolio", () => {
it("loads the account portfolio", async () => { it("loads the account portfolio", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
const { result } = renderHook(() => usePortfolio("account-1"), { const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
@@ -25,8 +27,8 @@ describe("usePortfolio", () => {
it("keeps the current connection snapshot after a background refetch error", async () => { it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true),
}); });
let calls = 0; let calls = 0;
server.use( server.use(
@@ -35,12 +37,12 @@ describe("usePortfolio", () => {
return calls === 1 return calls === 1
? HttpResponse.json( ? HttpResponse.json(
resource({ resource({
lastSuccessfulAt: "2026-08-14T00:00:00Z", cashBalances: [],
isStale: false, isStale: false,
totalsByCurrency: [], lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [], positions: [],
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [], totalsByCurrency: [],
}), }),
) )
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatShare } from "./display"; import { formatShare } from "./display";
describe("portfolio display helpers", () => { describe("portfolio display helpers", () => {

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