refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+5 -4
View File
@@ -69,10 +69,11 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui`
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай `ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md). [UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
`packages/icons`; package не зависит от UI, domain или motion. Приложение импортирует компоненты из `@wealthledger/icons` и подключает
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package `@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов,
Add/Edit/Delete; restore/category SVG остаются локальными. единственная задача которых — рисовать иконку. Domain mapping категории в
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
## Примеры ## Примеры
+3 -1
View File
@@ -19,7 +19,9 @@ StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions. Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions.
Общие Mantine/theme/i18n/Dates providers уже есть в Общие Mantine/theme/i18n/Dates providers уже есть в
[preview](../../.storybook/preview.tsx); не создавай второй theme source. [preview](../../.storybook/preview.tsx); не создавай второй theme source.
Stories не достигают application API, auth, VM и query providers даже транзитивно; Stories не достигают application API, auth и query providers даже транзитивно.
Presentation VM с form-context/UI-only state допустима; data VM остаётся за
этой границей, которую проверяет полный reachable graph;
границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога. границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога.
`play` через `storybook/test` нужен, когда interaction иллюстрирует собственный `play` через `storybook/test` нужен, когда interaction иллюстрирует собственный
+24
View File
@@ -17,6 +17,30 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
JSX siblings и вокруг многострочных declarations/control-flow/return, вынос
функций и условных выражений из JSX props, отдельные Zod schema files,
отсутствие SVG в приложении, optional trailing parameters и многострочные
сложные union types. Generated artifacts не форматируй вручную.
URL, строковые/template/regex literals и комментарии исключены из `max-len`;
это исключение для содержимого, а не повод собирать длинные expressions.
CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки.
Review проверяет то, чего синтаксис не доказывает:
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
и многострочное преобразование — отдельная функция с guard и `if/else`.
- Union из object shapes оформляй несколькими строками; nullable scalar
`null | string` остаётся компактным. Для повторяемого shape используй named type.
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
перед следующим required параметром может сохранять `T | undefined`.
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
## Hooks и derived state ## Hooks и derived state
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
+29 -11
View File
@@ -11,13 +11,11 @@ Mantine component и общую тему. Для выпадающего выбо
ограничения JSDOM решай в test setup, сохраняя production UI. ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным. Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например, Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx)); lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны иконки из model сущности. SVG декоративны по умолчанию; доступное имя action
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings и доступное имя. Приложение и Storybook подключают CSS пакета.
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
components живут у feature/entity/page, даже при повторном использовании.
Wrapper нужен для проектной ответственности, а не ради нового имени Wrapper нужен для проектной ответственности, а не ради нового имени
для Button. Локальный CSS module принадлежит component; общий token вводи для для Button. Локальный CSS module принадлежит component; общий token вводи для
@@ -51,10 +49,26 @@ retry callbacks, domain grouping и derived presentation data. View получа
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен | | `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
| `Component.module.css` | Локальные стили | | `Component.module.css` | Локальные стили |
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection. View получает готовые данные и именованные действия. Container вызывает VM;
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed VM владеет запросами, формой, состоянием, обработчиками и derived values.
image fallback) и formatter одного переданного значения. Группировка/доли Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу. вызывает container; presentation hook не должен скрывать fetching во view.
Presentation VM для form-context/UI-only state может использоваться автономно
в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view.
JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
Внутри компонента нет именованных helpers. Чистые функции форматирования и
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
Render-helper замени самостоятельным presentation component у того же owner.
Отдельный state/section с самостоятельной responsibility выноси в Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй `components/` своего component. Небольшой tightly-coupled renderer оставляй
@@ -87,6 +101,10 @@ image fallback) и formatter одного переданного значени
## Формы ## Формы
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`. form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
Во время submit блокируй все действия формы. Zod errors показывай у поля; Во время submit блокируй все действия формы. Zod errors показывай у поля;
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
name: "stories-must-not-reach-application-data", name: "stories-must-not-reach-application-data",
severity: "error", severity: "error",
to: { to: {
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)/`, path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
reachable: true, reachable: true,
}, },
}, },
+4 -2
View File
@@ -1,12 +1,14 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css"; import "@mantine/charts/styles.css";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@wealthledger/icons/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 type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react-vite";
import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
+11 -11
View File
@@ -262,14 +262,14 @@ Library build использует отдельный Vite config без app plu
Начальная версия manifest взята из приложения; отдельный release-процесс Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены. и публикация не настроены.
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon` Private `@wealthledger/icons` содержит все SVG приложения: действия,
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`; навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion. ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5; подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
цвет наследуется через `currentColor`, SVG props можно передать явно. иконок. Category/kind mapping остаётся в model `entities/bank-card`.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
доступное имя задавайте кнопке. Standalone пример: Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`. `strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller. имя принадлежит действию. Для самостоятельного изображения передайте
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth. `aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
Restore/category SVG и предметный BankOperationIcon остаются в приложении. Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`.
+178
View File
@@ -0,0 +1,178 @@
const readability = {
create(context) {
const filename = context.filename;
function checkParameters(node) {
const last = node.params.at(-1);
if (
last?.type === "Identifier"
&& !last.optional
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
&& last.typeAnnotation.typeAnnotation.types.some(
type => type.type === "TSUndefinedKeyword",
)
)
context.report({ messageId: "optional", node: last });
}
return {
ArrowFunctionExpression: checkParameters,
CallExpression(node) {
if (
node.callee.type === "MemberExpression"
&& ["refine", "superRefine"].includes(node.callee.property.name)
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
ImportDeclaration(node) {
if (
node.source.value === "zod"
&& node.importKind !== "type"
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
JSXOpeningElement(node) {
if (
!filename.includes("/packages/icons/")
&& node.name.type === "JSXIdentifier"
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
node.name.name,
)
)
context.report({ messageId: "svg", node });
},
TSUnionType(node) {
const complex
= node.types.length > 2
|| node.types.some(type => type.type === "TSTypeLiteral");
if (
complex
&& node.types.some(
(type, index) =>
index > 0
&& type.loc.start.line === node.types[index - 1].loc.end.line,
)
)
context.report({ messageId: "union", node });
},
VariableDeclarator(node) {
if (
!/^[A-Z]/.test(node.id.name ?? "")
|| node.init?.type !== "ArrowFunctionExpression"
|| node.init.body.type !== "BlockStatement"
)
return;
for (const statement of node.init.body.body) {
if (statement.type === "FunctionDeclaration")
context.report({ messageId: "helper", node: statement });
else if (statement.type === "VariableDeclaration") {
for (const declaration of statement.declarations) {
if (
["ArrowFunctionExpression", "FunctionExpression"].includes(
declaration.init?.type,
)
)
context.report({ messageId: "helper", node: declaration });
}
}
}
},
};
},
meta: {
messages: {
helper:
"Move helpers outside components; actions belong to the ViewModel.",
optional:
"Use an optional trailing parameter instead of a union with undefined.",
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.",
},
schema: [],
type: "suggestion",
},
};
function isComponent(node) {
const pending = [node];
while (pending.length > 0) {
const child = pending.pop();
if (!child) continue;
switch (child.type) {
case "JSXElement": {
return true;
}
case "JSXFragment": {
return true;
}
case "JSXExpressionContainer": {
pending.push(child.expression);
continue;
}
case "LogicalExpression": {
pending.push(child.right);
continue;
}
case "ConditionalExpression": {
pending.push(child.consequent, child.alternate);
continue;
}
}
}
return false;
}
const jsxSpacing = {
create(context) {
function check(node) {
let previous;
for (const child of node.children) {
if (child.type === "JSXText" && !child.value.trim()) continue;
if (isComponent(child)) {
if (previous) {
const gap = context.sourceCode.text.slice(
previous.range[1],
child.range[0],
);
if (!/\n\s*\n/.test(gap))
context.report({
fix: fixer => fixer.insertTextAfter(previous, "\n"),
messageId: "space",
node: child,
});
}
previous = child;
}
else previous = undefined;
}
}
return { JSXElement: check, JSXFragment: check };
},
meta: {
fixable: "whitespace",
messages: { space: "Separate sibling JSX components with a blank line." },
schema: [],
type: "layout",
},
};
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
+163 -43
View File
@@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals"; import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
export default defineConfig( export default defineConfig(
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
plugins: { frontend },
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
},
globalIgnores([ globalIgnores([
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**", "**/dist/**",
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**", "dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]), ]),
{ {
extends: [ extends: [
@@ -35,32 +49,91 @@ export default defineConfig(
security.configs.recommended, security.configs.recommended,
noUnsanitized.configs.recommended, noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"], perfectionist.configs["recommended-natural"],
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }), stylistic.configs.customize({
indent: "tab",
jsx: true,
quotes: "double",
semi: true,
}),
], ],
files: ["**/*.{js,mjs,cjs,ts,tsx}"], files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" }, linterOptions: { reportUnusedDisableDirectives: "error" },
rules: { rules: {
// A comma disambiguates generic arrow functions from JSX in TSX. // A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": ["error", { "@stylistic/comma-dangle": [
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline", "error",
functions: "always-multiline", generics: "ignore", imports: "always-multiline", {
objects: "always-multiline", tuples: "always-multiline", 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. // Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }], "@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/max-len": [
"error",
{
code: 80,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
"@stylistic/multiline-ternary": ["error", "always-multiline"], "@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }], "@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"], "@stylistic/operator-linebreak": ["error", "before"],
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }], "@stylistic/padding-line-between-statements": [
"error",
{ blankLine: "always", next: "return", prev: "*" },
{
blankLine: "always",
next: "*",
prev: ["multiline-const", "multiline-let", "block-like", "type"],
},
{
blankLine: "always",
next: ["multiline-const", "multiline-let", "block-like", "type"],
prev: "*",
},
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
],
"no-nested-ternary": "error",
"perfectionist/sort-classes": [
"error",
{
groups: ["static-method", "property", "method", "unknown"],
type: "natural",
},
],
// Preserve the repository's component, hook and config file naming conventions. // Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }], "unicorn/filename-case": [
"error",
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
],
// React uses Props/Ref names, including the recommended React ref-name rule. // React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }], "unicorn/name-replacements": [
"error",
{ replacements: { props: false, ref: false } },
],
// React, Mantine and Fetch accept null at their documented call boundaries. // React, Mantine and Fetch accept null at their documented call boundaries.
"unicorn/no-null": ["error", { checkArguments: false }], "unicorn/no-null": ["error", { checkArguments: false }],
"unicorn/prefer-ternary": ["error", "only-single-line"],
}, },
settings: { settings: {
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })], "import-x/resolver-next": [
createTypeScriptImportResolver({ project: "tsconfig.json" }),
],
}, },
}, },
{ {
@@ -68,18 +141,37 @@ export default defineConfig(
languageOptions: { globals: globals.node }, languageOptions: { globals: globals.node },
}, },
{ {
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
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. // React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }], "@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
{ {
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]], extends: [
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"], react.configs["recommended-type-checked"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
languageOptions: { globals: globals.browser }, languageOptions: { globals: globals.browser },
rules: { rules: {
"import-x/exports-last": "error", "import-x/exports-last": "error",
@@ -95,10 +187,16 @@ export default defineConfig(
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: { rules: {
"@typescript-eslint/no-magic-numbers": ["error", { "@typescript-eslint/no-magic-numbers": [
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true, "error",
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true, {
}], ignore: [-1, 0, 1, "-1n", "0n", "1n"],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
ignoreTypeIndexes: true,
},
],
}, },
}, },
{ {
@@ -110,51 +208,73 @@ export default defineConfig(
}, },
}, },
{ {
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]], extends: [
testingLibrary.configs["flat/react"],
jestDom.configs["flat/recommended"],
],
files: ["**/*.test.tsx"], files: ["**/*.test.tsx"],
}, },
storybook.configs["flat/recommended"], storybook.configs["flat/recommended"],
{ {
files: ["**/*.stories.tsx", ".storybook/preview.tsx"], files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" }, rules: {
"import-x/exports-last": "off",
"import-x/no-default-export": "off",
},
}, },
{ {
files: [ files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
"src/**/*.{ts,tsx}", ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
"packages/*/src/**/*.{ts,tsx}",
],
ignores: [
"**/*.test.{ts,tsx}",
"**/*.stories.tsx",
"src/test/**",
],
rules: { rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error", "@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [ "no-restricted-syntax": [
"error", "error",
{ {
message: "Use the owning enum member for machine codes and protocol values.", message:
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)", "Pass a named handler from the ViewModel; prepare prop data before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
}, },
{ {
message: "Use the owning enum member instead of a template machine code.", message: "Name the derived prop value before JSX.",
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)", selector:
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
},
{
message:
"Use the owning enum member for machine codes and protocol values.",
selector:
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
}, },
{ {
message: "Use an enum member instead of an inline code or protocol value.", message:
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]", "Use the owning enum member instead of a template machine code.",
selector:
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
}, },
{ {
message: "Use an enum member instead of constructing a code or protocol value.", message:
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value", "Use an enum member instead of an inline code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message:
"Use an enum member instead of constructing a code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
}, },
{ {
message: "Use a computed enum member as the protocol mapping key.", message: "Use a computed enum member as the protocol mapping key.",
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]", selector:
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
}, },
{ {
message: "Compare codes with their enum members, not string literals.", message:
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]", "Compare codes with their enum members, not string literals.",
selector:
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
}, },
], ],
}, },
+5 -2
View File
@@ -10,9 +10,12 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./styles.css": "./dist/index.css"
}, },
"sideEffects": false, "sideEffects": [
"**/*.css"
],
"scripts": { "scripts": {
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
+5
View File
@@ -17,6 +17,7 @@ const meta = {
} satisfies Meta<typeof AddIcon>; } satisfies Meta<typeof AddIcon>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Decorative: Story = { export const Decorative: Story = {
@@ -25,9 +26,11 @@ export const Decorative: Story = {
<button aria-label="Добавить пример" type="button"> <button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} /> <AddIcon size={20} strokeWidth={2} />
</button> </button>
<button aria-label="Изменить пример" type="button"> <button aria-label="Изменить пример" type="button">
<EditIcon size={18} /> <EditIcon size={18} />
</button> </button>
<button aria-label="Удалить пример" type="button"> <button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} /> <DeleteIcon size={18} />
</button> </button>
@@ -39,7 +42,9 @@ export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" /> <AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" /> <EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" /> <DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
+1
View File
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
<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. // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
@@ -0,0 +1,86 @@
import type { FC } from "react";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "./OperationIcon.module.css";
const paths = {
basket:
"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",
car:
"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",
cashWithdrawal: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
coffee:
"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",
deposit:
"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",
dice:
"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",
download: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
education: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
grid: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
growth: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
home: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8",
medical: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z",
music: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
refund: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14",
repayment: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
server:
"M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
shield: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
shopping: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
smoking:
"M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
subscriptions:
"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",
taxi:
"M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
topup:
"M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
transfer: "M3 7h18l-4-4 M21 17H3l4 4",
travel: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
wifi:
"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",
wine:
"M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
} as const;
type OperationIconProps = {
animation?: string;
color: string;
variant: keyof typeof paths;
};
const OperationIcon: FC<OperationIconProps> = (props) => {
const { animation, color, variant } = props;
// eslint-disable-next-line security/detect-object-injection -- Animation keys select CSS module classes only.
const animationClass = animation ? classes[animation] : "";
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed keyof the package geometry map.
const parts = paths[variant]
.split(" M")
.map((part, index) => ({ d: index ? `M${part}` : part, key: part }));
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${animationClass}`}
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{parts.map(part => (
<path d={part.d} key={part.key} pathLength={1} />
))}
</svg>
);
};
export { OperationIcon, type OperationIconProps };

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -9,25 +9,30 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
vi.spyOn(globalThis, "matchMedia").mockReturnValue({ vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true, matches: true,
} as MediaQueryList); } as MediaQueryList);
const { container } = render( const { container } = render(
<button type="button"> <button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} /> <svg aria-hidden="true" ref={bindIconAnimation} />
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button"); const target = screen.getByRole("button");
let finishFirst!: () => void; let finishFirst!: () => void;
let finishLast!: () => void; let finishLast!: () => void;
const getAnimations = vi.fn(() => { const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>(); const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>(); const last = Promise.withResolvers<void>();
finishFirst = first.resolve; finishFirst = first.resolve;
finishLast = last.resolve; finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }]; return [{ finished: first.promise }, { finished: last.promise }];
}); });
Object.defineProperty(icon, "getAnimations", { value: getAnimations }); Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target); fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target); fireEvent.mouseLeave(target);
@@ -53,6 +58,7 @@ it("keeps playing after keyboard blur and releases cancelled animations", async
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
@@ -13,6 +13,7 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
delete icon.dataset.iconAnimating; delete icon.dataset.iconAnimating;
} }
}; };
const start = () => { const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return; if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = ""; icon.dataset.iconAnimating = "";
@@ -27,21 +28,25 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
release(); release();
}); });
}; };
const enter = () => { const enter = () => {
if (!matchMedia("(hover: hover)").matches) return; if (!matchMedia("(hover: hover)").matches) return;
isHovered = true; isHovered = true;
start(); start();
}; };
const leave = () => { const leave = () => {
isHovered = false; isHovered = false;
release(); release();
}; };
const focus = () => { const focus = () => {
isFocused isFocused
= target.matches(":focus-visible") = target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible"); || !!target.querySelector(":focus-visible");
if (isFocused) start(); if (isFocused) start();
}; };
const blur = (event: FocusEvent) => { const blur = (event: FocusEvent) => {
if ( if (
event.relatedTarget instanceof Node event.relatedTarget instanceof Node
@@ -51,10 +56,12 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
isFocused = false; isFocused = false;
release(); release();
}; };
target.addEventListener("mouseenter", enter); target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave); target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus); target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur); target.addEventListener("focusout", blur);
return () => { return () => {
isDisposed = true; isDisposed = true;
target.removeEventListener("mouseenter", enter); target.removeEventListener("mouseenter", enter);
+13
View File
@@ -1,4 +1,17 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
export { AddIcon } from "./svg/AddIcon"; export { AddIcon } from "./svg/AddIcon";
export { BondsIcon } from "./svg/BondsIcon";
export { CardsIcon } from "./svg/CardsIcon";
export { CategoriesIcon } from "./svg/CategoriesIcon";
export { ContactlessIcon } from "./svg/ContactlessIcon";
export { DeleteIcon } from "./svg/DeleteIcon"; export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon"; export { EditIcon } from "./svg/EditIcon";
export { PortfolioIcon } from "./svg/PortfolioIcon";
export { RestoreIcon } from "./svg/RestoreIcon";
export { SettingsIcon } from "./svg/SettingsIcon";
export { StocksIcon } from "./svg/StocksIcon";
export { WalletIcon } from "./svg/WalletIcon";
export type { IconProps } from "./types"; export type { IconProps } from "./types";
+179
View File
@@ -0,0 +1,179 @@
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
+1
View File
@@ -4,6 +4,7 @@ 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
aria-hidden="true" aria-hidden="true"
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const BondsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</svg>
);
};
export { BondsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const CardsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</svg>
);
};
export { CardsIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const CategoriesIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
);
};
export { CategoriesIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const ContactlessIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path
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"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
};
export { ContactlessIcon };
+1
View File
@@ -4,6 +4,7 @@ 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
aria-hidden="true" aria-hidden="true"
+1
View File
@@ -4,6 +4,7 @@ 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
aria-hidden="true" aria-hidden="true"
+43
View File
@@ -0,0 +1,43 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const PortfolioIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</svg>
);
};
export { PortfolioIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const RestoreIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
);
};
export { RestoreIcon };
+35
View File
@@ -0,0 +1,35 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const SettingsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</svg>
);
};
export { SettingsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const StocksIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</svg>
);
};
export { StocksIcon };
+29
View File
@@ -0,0 +1,29 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const WalletIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
);
};
export { WalletIcon };
+11 -3
View File
@@ -1,7 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": [
"ES2023",
"DOM"
],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true, "strict": true,
@@ -11,7 +14,12 @@
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"jsx": "react-jsx" "jsx": "react-jsx",
"types": [
"vite/client"
]
}, },
"include": ["src/index.ts"] "include": [
"src/index.ts"
]
} }
@@ -35,5 +35,7 @@ const meta = {
} satisfies Meta<typeof EntryCard>; } satisfies Meta<typeof EntryCard>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
@@ -31,11 +31,13 @@ describe("EntryCard", () => {
value: "—", value: "—",
valueLabel: "Неизвестно", valueLabel: "Неизвестно",
}; };
const { 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 = screen.queryByAltText(""); const image = screen.queryByAltText("");
assert(image); assert(image);
+21 -17
View File
@@ -13,24 +13,25 @@ type EntryCardProps = {
initial: string; initial: string;
metadata: string; metadata: string;
title: string; title: string;
tone: "negative" | "neutral" | "positive"; tone: "negative"
| "neutral"
| "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
return ( const surfaceStyle = props.color
<Paper ? {
className={classes.row} background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
style={
(props.color)
? ({
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
})
: undefined
} }
withBorder : undefined;
>
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return (
<Paper className={classes.row} style={surfaceStyle} withBorder>
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
@@ -40,26 +41,29 @@ const EntryCard: FC<EntryCardProps> = (props) => {
> >
{props.icon ?? props.initial} {props.icon ?? props.initial}
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={1}>
{props.title} {props.title}
</Text> </Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{props.metadata} {props.metadata}
</Text> </Text>
{props.details} {props.details}
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<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}>
<Text <div className={valueContainerClass}>
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`} <Text className={valueLabelClass} size="sm">
size="sm"
>
{props.valueLabel} {props.valueLabel}
</Text> </Text>
{Boolean(props.actions) && ( {Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div> <div className={classes.actions}>{props.actions}</div>
)} )}
@@ -23,7 +23,9 @@ const meta = {
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...arguments_}> <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>
@@ -33,5 +35,7 @@ const meta = {
} satisfies Meta<typeof LoadingSkeleton>; } satisfies Meta<typeof LoadingSkeleton>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" }; export const Initial: Story = { name: "Загрузка области" };
@@ -41,16 +41,17 @@ const meta = {
}, },
render: (arguments_) => { render: (arguments_) => {
const { onAction, withAction, ...stateProps } = arguments_; const { onAction, withAction, ...stateProps } = arguments_;
return ( return (
<StateView <StateView
{...stateProps} {...stateProps}
action={ action={
(withAction) withAction
? (( ? (
<Button onClick={onAction} variant="default"> <Button onClick={onAction} variant="default">
{arguments_.error ? "Повторить локально" : "Выполнить пример"} {arguments_.error ? "Повторить локально" : "Выполнить пример"}
</Button> </Button>
)) )
: undefined : undefined
} }
/> />
@@ -63,6 +64,7 @@ const meta = {
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 = {
args: { withAction: true }, args: { withAction: true },
+2
View File
@@ -17,7 +17,9 @@ const StateView: FC<StateViewProps> = (props) => {
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder> <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>
{action} {action}
</Stack> </Stack>
</Paper> </Paper>
+3 -1
View File
@@ -1,4 +1,3 @@
import "./styles.css";
import { import {
colorsTuple, colorsTuple,
createTheme, createTheme,
@@ -11,8 +10,11 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json"; import designTokens from "@wealthledger/tokens/tokens.json";
import "./styles.css";
const { breakpoints, color, radius, shadows, spacing, typography } 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)]),
+10 -1
View File
@@ -14,14 +14,20 @@ 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(nodePath.join(tmpdir(), "wl-image-retry-")); const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync( const workflow = readFileSync(
nodePath.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", 2)[1] .split(
" - name: Publish immutable release tag\n run: |\n",
2,
)[1]
.split(" - name:", 1)[0] .split(" - name:", 1)[0]
.replaceAll(/^ {10}/gm, ""); .replaceAll(/^ {10}/gm, "");
const double = String.raw`#!${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(' ');
@@ -39,6 +45,7 @@ if (args.startsWith('manifest inspect')) {
console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit'); console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1); } else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
`; `;
try { try {
for (const name of ["docker", "npm"]) { for (const name of ["docker", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
@@ -46,6 +53,7 @@ if (args.startsWith('manifest inspect')) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // 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); chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
} }
const run = (extra = {}) => const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], { spawnSync("/bin/sh", ["-ec", block], {
cwd: directory, cwd: directory,
@@ -59,6 +67,7 @@ if (args.startsWith('manifest inspect')) {
...extra, ...extra,
}, },
}); });
assert.notEqual(run().status, 0); assert.notEqual(run().status, 0);
const retry = run(); const retry = run();
assert.equal(retry.status, 0, retry.stdout + retry.stderr); assert.equal(retry.status, 0, retry.stdout + retry.stderr);
+2
View File
@@ -6,6 +6,7 @@ import nodePath from "node:path";
const temporaryDirectory = await mkdtemp( const temporaryDirectory = await mkdtemp(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"), nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
); );
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts"); const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts"); const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js"); const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
@@ -24,6 +25,7 @@ try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // 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) {
console.error( console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.", "Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
+5 -2
View File
@@ -2,14 +2,16 @@ 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");
// 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(); const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
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 tag_ = git( const tag_ = git(
"tag", "tag",
"--merged", "--merged",
@@ -20,6 +22,7 @@ const tag_ = git(
) )
.split("\n") .split("\n")
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag)); .find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal( assert.equal(
version, version,
tag_?.slice(1) ?? "0.0.0", tag_?.slice(1) ?? "0.0.0",
+116 -9
View File
@@ -16,11 +16,13 @@ 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 assert from "node:assert/strict"; import assert from "node:assert/strict";
import { globSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import tseslint from "typescript-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();
for (const [filePath, code, rule] of [ for (const [filePath, code, rule] of [
[ [
"src/shared/api/problem-details.ts", "src/shared/api/problem-details.ts",
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
rule, rule,
); );
} }
for (const file of [ for (const file of [
"src/shared/api/generated/schema.d.ts", "src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js", "packages/ui/dist/index.js",
@@ -117,9 +120,11 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"coverage/index.js", "coverage/index.js",
]) ])
assert.equal(await eslint.isPathIgnored(file), true, file); assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile( const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx", "src/shared/ui/Button.stories.tsx",
); );
assert.equal(story.rules["import-x/no-default-export"][0], 0); assert.equal(story.rules["import-x/no-default-export"][0], 0);
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);
@@ -130,6 +135,7 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
const source = "src/shared/api/problem-details.ts"; const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx"; const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx"; const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [ for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source], [tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source], [[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
@@ -147,12 +153,25 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
[vitest.configs.recommended, unit], [vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit], [testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], 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"]), ...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 configs = [preset].flat();
const expected = Object.assign({}, ...configs.map(config => config.rules));
const expected = Object.assign(
{},
...configs.map(config => config.rules),
);
const config = await eslint.calculateConfigForFile(filePath); const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules)); const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) { for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting; const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue; if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
@@ -164,10 +183,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => { test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText( const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};", "//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath }, { filePath },
); );
assert.equal(fixed.fatalErrorCount, 0); assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0); assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output); assert.ok(fixed.output);
@@ -178,10 +199,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async (
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => { test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText( const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };", "const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" }, { filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
); );
assert.equal(fixed.errorCount, 0); assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}")); assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
}); });
@@ -190,11 +213,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => { test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
const eslint = new ESLint(); const eslint = new ESLint();
const filePath = "src/shared/api/problem-details.ts"; const filePath = "src/shared/api/problem-details.ts";
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
for (const code of [
"export function check(code: string) { return code === \"one-off-code\"; }",
"export const result = `MOEX_UNAVAILABLE`;",
"export const result = { code: \"one-off-code\" };",
"export const result = { \"errorCode\": \"one-off-code\" };",
"export const result = { code: `dynamic-${Date.now()}` };",
"export const result = \"MOEX_UNAVAILABLE\";",
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code); assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code); assert.ok(
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
code,
);
} }
for (const code of [ for (const code of [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };", "enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"export const select = { code: true, errorCode: true };", "export const select = { code: true, errorCode: true };",
@@ -202,10 +239,80 @@ test("machine codes require enum members without banning ordinary text or Prisma
]) { ]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code); assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code); assert.ok(
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
code,
);
} }
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax")); const [fixture] = await eslint.lintText(
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath }); "export const result = { code: \"MOEX_UNAVAILABLE\" };",
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison")); { filePath: "src/shared/api/client.test.ts" },
);
assert.ok(
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
);
const [comparison] = await eslint.lintText(
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
{ filePath },
);
assert.ok(
comparison.messages.some(
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
),
);
});
test("readability enforces syntax without banning VM and schema patterns", async () => {
const eslint = new ESLint();
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
const model = "src/entities/income/api/useIncome.ts";
const invalid = [
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
];
for (const [filePath, code, rule] of invalid) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
}
for (const [filePath, code] of [
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
}
assert.deepEqual(globSync("src/**/*.svg"), []);
});
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
const output = fixed.output;
assert.ok(output);
const [again] = await eslint.lintText(output, { filePath });
assert.equal(again.output, undefined);
assert.equal(again.errorCount, 0);
}); });
+14 -5
View File
@@ -20,23 +20,32 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
const bin = nodePath.join(cwd, "bin"); const bin = nodePath.join(cwd, "bin");
const marker = nodePath.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")}`;
// 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_, { cwd, stdio: "pipe" }); const git = (...arguments_) =>
execFileSync("git", arguments_, { cwd, stdio: "pipe" }); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) => const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], { spawnSync("/bin/sh", ["-e", hook], {
cwd, cwd,
encoding: "utf8", encoding: "utf8",
env: { ...process.env, PATH: path }, 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. // 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"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // 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", { writeFileSync(
mode: 0o755, nodePath.join(bin, command),
}); "#!/bin/sh\necho ran >> downstream\n",
{
mode: 0o755,
},
);
} }
git("init", "--quiet"); git("init", "--quiet");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // 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"); writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
+76 -42
View File
@@ -28,8 +28,11 @@ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
]; ];
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position: Portfolio["positions"][number]
= {
absoluteReturn: null, absoluteReturn: null,
averagePurchasePrice: null, averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" }, currentValue: { currency: "RUB", value: "600" },
@@ -40,8 +43,9 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
quantity: "2", quantity: "2",
ticker: "SBER", ticker: "SBER",
type: "STOCK", type: "STOCK",
} };
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
const portfolio: Portfolio = { const portfolio: Portfolio = {
cashBalances: [], cashBalances: [],
isStale: false, isStale: false,
@@ -50,8 +54,11 @@ const portfolio: Portfolio = {
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }], totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const receipt: BrokerageOperation
= {
accruedInterest: null, accruedInterest: null,
category: "income", category: "income",
commission: null, commission: null,
@@ -67,11 +74,15 @@ const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixtur
quantity: null, quantity: null,
ticker: "BOND", ticker: "BOND",
type: "OPERATION_TYPE_COUPON", type: "OPERATION_TYPE_COUPON",
} };
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
const items = accounts.map(account => ({ ...receipt, account })); const items = accounts.map(account => ({ ...receipt, account }));
const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const analytics: IncomeAnalytics
= {
accountId: "all", accountId: "all",
bucketGranularity: "day", bucketGranularity: "day",
buckets: [], buckets: [],
@@ -105,17 +116,21 @@ const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture
}, },
], ],
yearComparison: null, yearComparison: null,
} };
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
const location = useLocation(); const location = useLocation();
return ( return (
<output aria-label="Текущий путь"> <output aria-label="Текущий путь">
{location.pathname} {location.pathname}
{location.search} {location.search}
</output> </output>
); );
}; };
function renderPage(path: string) { function renderPage(path: string) {
const client = createTestQueryClient(); const client = createTestQueryClient();
render( render(
@@ -124,12 +139,14 @@ function renderPage(path: string) {
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<AuthProvider> <AuthProvider>
<AppRoutes /> <AppRoutes />
<CurrentLocation /> <CurrentLocation />
</AuthProvider> </AuthProvider>
</MemoryRouter> </MemoryRouter>
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
return client; return client;
} }
@@ -144,14 +161,17 @@ beforeEach(() => {
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ accounts.map(
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
...account, account =>
openedOn: null, ({
portfolioSummary: null, ...account,
type: "BROKERAGE", openedOn: null,
} portfolioSummary: null,
/* eslint-enable unicorn/no-null */))), type: "BROKERAGE",
}),
/* eslint-enable unicorn/no-null */
),
), ),
), ),
), ),
@@ -162,15 +182,17 @@ beforeEach(() => {
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) => ({ params }) =>
HttpResponse.json( HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ {
accountId: params.accountId, accountId: params.accountId,
hasNext: false, hasNext: false,
items, items,
nextCursor: null, nextCursor: null,
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
} },
/* eslint-enable unicorn/no-null */)), /* eslint-enable unicorn/no-null */
),
), ),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
@@ -181,21 +203,24 @@ 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. */ events: accounts.map(
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account, account =>
date: "2026-09-15", ({
dateKind: "event", account,
displayName: "Облигация", date: "2026-09-15",
figi: null, dateKind: "event",
instrumentUid: null, displayName: "Облигация",
isin: null, figi: null,
logoName: null, instrumentUid: null,
projectedAmount: { currency: "RUB", value: "2" }, isin: null,
ticker: "BOND", logoName: null,
type: "coupon", projectedAmount: { currency: "RUB", value: "2" },
} ticker: "BOND",
/* eslint-enable unicorn/no-null */))), type: "coupon",
}),
/* eslint-enable unicorn/no-null */
),
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
}), }),
), ),
@@ -235,7 +260,11 @@ describe("All accounts routes", () => {
); );
for (const suffix of ["operations", "income", "events"]) for (const suffix of ["operations", "income", "events"])
expect( expect(
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`), screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -247,8 +276,10 @@ describe("All accounts routes", () => {
({ params, request }) => { ({ 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((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ {
accountId: params.accountId, accountId: params.accountId,
hasNext: !url.searchParams.has("cursor"), hasNext: !url.searchParams.has("cursor"),
@@ -262,8 +293,9 @@ describe("All accounts routes", () => {
], ],
nextCursor: url.searchParams.has("cursor") ? null : "next", nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
} },
/* eslint-enable unicorn/no-null */)), /* eslint-enable unicorn/no-null */
),
); );
}, },
), ),
@@ -281,9 +313,11 @@ describe("All accounts routes", () => {
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");
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z"); expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
expect(selected?.searchParams.get("categories")).toBe("income"); expect(selected?.searchParams.get("categories")).toBe("income");
+6 -3
View File
@@ -62,6 +62,7 @@ describe("AppRoutes", () => {
server.use( server.use(
http.post("*/api/v1/auth/login", async ({ request }) => { http.post("*/api/v1/auth/login", async ({ request }) => {
credentials = await request.json(); credentials = await request.json();
return HttpResponse.json({ return HttpResponse.json({
accessToken: "login-token", accessToken: "login-token",
user: { email: "user@example.com", id: "user-1" }, user: { email: "user@example.com", id: "user-1" },
@@ -103,7 +104,8 @@ describe("AppRoutes", () => {
), ),
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((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{ {
cardId: null, cardId: null,
categories: [], categories: [],
@@ -111,8 +113,9 @@ describe("AppRoutes", () => {
month: "2026-08", month: "2026-08",
net: { currency: "RUB", value: "0" }, net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" }, refunds: { currency: "RUB", value: "0" },
} },
/* eslint-enable unicorn/no-null */)), /* eslint-enable unicorn/no-null */
),
), ),
); );
renderRoutes("/cards?month=2026-08"); renderRoutes("/cards?month=2026-08");
+50
View File
@@ -8,72 +8,104 @@ import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => { const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card"); const { CardPage } = await import("@/pages/card");
return { default: CardPage }; return { default: CardPage };
}); });
const CardsPage = lazy(async () => { const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards"); const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage }; return { default: CardsPage };
}); });
const BondPage = lazy(async () => { const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond"); const { BondPage } = await import("@/pages/bond");
return { default: BondPage }; return { default: BondPage };
}); });
const BondsPage = lazy(async () => { const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds"); const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage }; return { default: BondsPage };
}); });
const StockPage = lazy(async () => { const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock"); const { StockPage } = await import("@/pages/stock");
return { default: StockPage }; return { default: StockPage };
}); });
const StocksPage = lazy(async () => { const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks"); const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage }; return { default: StocksPage };
}); });
const AccountPage = lazy(async () => { const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account"); const { AccountPage } = await import("@/pages/account");
return { default: AccountPage }; return { default: AccountPage };
}); });
const AccountEventsPage = lazy(async () => { const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events"); const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage }; return { default: AccountEventsPage };
}); });
const AccountOperationsPage = lazy(async () => { const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations"); const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage }; return { default: AccountOperationsPage };
}); });
const AccountIncomePage = lazy(async () => { const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income"); const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage }; return { default: AccountIncomePage };
}); });
const AccountsPage = lazy(async () => { const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts"); const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage }; return { default: AccountsPage };
}); });
const LoginPage = lazy(async () => { const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login"); const { LoginPage } = await import("@/pages/login");
return { default: LoginPage }; return { default: LoginPage };
}); });
const NotFoundPage = lazy(async () => { const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found"); const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage }; return { default: NotFoundPage };
}); });
const RegisterPage = lazy(async () => { const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register"); const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage }; return { default: RegisterPage };
}); });
const SettingsPage = lazy(async () => { const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings"); const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage }; return { default: SettingsPage };
}); });
const ProtectedLayout = lazy(async () => { const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout"); const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout }; return { default: ProtectedLayout };
}); });
const AppRoutes: FC = () => ( const AppRoutes: FC = () => (
<Routes> <Routes>
<Route element={<Navigate replace to="/accounts" />} path="/" /> <Route element={<Navigate replace to="/accounts" />} path="/" />
<Route <Route
element={( element={(
<Public> <Public>
@@ -84,6 +116,7 @@ const AppRoutes: FC = () => (
)} )}
path="/login" path="/login"
/> />
<Route <Route
element={( element={(
<Public> <Public>
@@ -94,6 +127,7 @@ const AppRoutes: FC = () => (
)} )}
path="/register" path="/register"
/> />
<Route <Route
element={( element={(
<Protected> <Protected>
@@ -111,6 +145,7 @@ const AppRoutes: FC = () => (
)} )}
path="/cards/:cardId" path="/cards/:cardId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -119,6 +154,7 @@ const AppRoutes: FC = () => (
)} )}
path="/cards" path="/cards"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -127,6 +163,7 @@ const AppRoutes: FC = () => (
)} )}
path="/bonds" path="/bonds"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -135,6 +172,7 @@ const AppRoutes: FC = () => (
)} )}
path="/bonds/:bondId" path="/bonds/:bondId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -143,6 +181,7 @@ const AppRoutes: FC = () => (
)} )}
path="/stocks" path="/stocks"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -151,6 +190,7 @@ const AppRoutes: FC = () => (
)} )}
path="/stocks/:stockId" path="/stocks/:stockId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -159,6 +199,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts" path="/accounts"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -167,6 +208,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all" path="/accounts/all"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -175,6 +217,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/operations" path="/accounts/all/operations"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -183,6 +226,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/income" path="/accounts/all/income"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -191,6 +235,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/events" path="/accounts/all/events"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -199,6 +244,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId" path="/accounts/:accountId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -207,6 +253,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/events" path="/accounts/:accountId/events"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -215,6 +262,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/income" path="/accounts/:accountId/income"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -223,6 +271,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/operations" path="/accounts/:accountId/operations"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -232,6 +281,7 @@ const AppRoutes: FC = () => (
path="/settings" path="/settings"
/> />
</Route> </Route>
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -21,11 +21,13 @@ import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => { const Navigation: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<button onClick={() => navigate("/accounts/all/income")} type="button"> <button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета Все счета
</button> </button>
<button onClick={() => navigate(-1)} type="button"> <button onClick={() => navigate(-1)} type="button">
Назад Назад
</button> </button>
@@ -39,6 +41,7 @@ const renderTitle = (path: string) =>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<AuthProvider> <AuthProvider>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<Navigation /> <Navigation />
</AuthProvider> </AuthProvider>
</MemoryRouter> </MemoryRouter>
@@ -59,10 +62,13 @@ 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 () => {
const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers<void>(); const { promise: accountsReady, resolve: resolveAccounts }
= Promise.withResolvers<void>();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", async () => { http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady; await accountsReady;
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }])); return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}), }),
); );
@@ -102,6 +108,7 @@ describe("document titles", () => {
server.use( server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => { http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
isRequested = true; isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}), }),
); );
+18 -1
View File
@@ -24,29 +24,36 @@ 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"));
let section = t("portfolio.title"); let section = t("portfolio.title");
switch (props.section ?? "") { switch (props.section ?? "") {
case "income": { case "income": {
section = t("income.title"); section = t("income.title");
break; break;
} }
case "operations": { case "operations": {
section = t("ledger.title"); section = t("ledger.title");
break; break;
} }
case "events": { case "events": {
section = t("events.title"); section = t("events.title");
break; break;
} }
default: { default: {
break; break;
} }
} }
return <DocumentTitle title={`${section} — ${accountName}`} />; return <DocumentTitle title={`${section} — ${accountName}`} />;
}; };
@@ -55,18 +62,21 @@ type InstrumentDocumentTitleProps = { identifier: string };
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBond(props.identifier); const { data } = useBond(props.identifier);
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />; return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
}; };
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useStock(props.identifier); const { data } = useStock(props.identifier);
return <DocumentTitle title={data?.name ?? t("stock.share")} />; return <DocumentTitle title={data?.name ?? t("stock.share")} />;
}; };
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBankCard(props.identifier); const { data } = useBankCard(props.identifier);
return <DocumentTitle title={data?.name ?? t("bank.card")} />; return <DocumentTitle title={data?.name ?? t("bank.card")} />;
}; };
@@ -94,7 +104,14 @@ const ProtectedDocumentTitle: FC = () => {
if (cards) return <DocumentTitle title={t("navigation.cards")} />; if (cards) return <DocumentTitle title={t("navigation.cards")} />;
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />; if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />; if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
return settings
? (
<DocumentTitle title={t("navigation.settings")} />
)
: (
<DocumentTitle title={t("navigation.accounts")} />
);
}; };
export { ProtectedDocumentTitle }; export { ProtectedDocumentTitle };
+1
View File
@@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => {
return ( return (
<> <>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<AppShell contentAboveOutlet={<AppBreadcrumbs />} /> <AppShell contentAboveOutlet={<AppBreadcrumbs />} />
</> </>
); );
@@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary";
const session = { isAuthenticated: false }; const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({ vi.mock("@/features/auth", () => ({
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ user: session.isAuthenticated ? {} : null } useAuth: () =>
/* eslint-enable unicorn/no-null */)), ({ user: session.isAuthenticated ? {} : null }),
/* eslint-enable unicorn/no-null */
})); }));
const ThrowingRoute: FC = () => { const ThrowingRoute: FC = () => {
@@ -39,6 +40,7 @@ const RouteHarness: FC = () => {
<button onClick={() => navigate("/healthy")} type="button"> <button onClick={() => navigate("/healthy")} type="button">
go healthy go healthy
</button> </button>
<RouteErrorBoundary> <RouteErrorBoundary>
<HealthyRoute /> <HealthyRoute />
</RouteErrorBoundary> </RouteErrorBoundary>
@@ -62,10 +64,13 @@ describe("RouteErrorBoundary", () => {
async (path) => { async (path) => {
session.isAuthenticated = path === "/accounts"; session.isAuthenticated = path === "/accounts";
let isFailing = true; let isFailing = true;
const RecoverableRoute: FC = () => { const RecoverableRoute: FC = () => {
if (isFailing) throw new Error("temporary failure"); if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>; return <div>recovered route</div>;
}; };
render( render(
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
@@ -116,10 +121,12 @@ describe("RouteErrorBoundary", () => {
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 isIntercepted = true; let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => { const preventNavigation = (event: MouseEvent) => {
isIntercepted = 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(isIntercepted).toBe(false); expect(isIntercepted).toBe(false);
@@ -33,7 +33,13 @@ class Boundary extends Component<BoundaryProps, State> {
} }
render() { render() {
return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children; return this.state.hasError
? (
<RouteErrorFallback authenticated={this.props.authenticated} />
)
: (
this.props.children
);
} }
} }
@@ -46,10 +52,13 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
return ( return (
<Stack> <Stack>
<Title order={1}>{t("routeError.title")}</Title> <Title order={1}>{t("routeError.title")}</Title>
<Text>{t("routeError.text")}</Text> <Text>{t("routeError.text")}</Text>
<Button component={Link} to={to}> <Button component={Link} to={to}>
{t("routeError.action")} {t("routeError.action")}
</Button> </Button>
<Button component="a" href={to} variant="default"> <Button component="a" href={to} variant="default">
{t("routeError.reload")} {t("routeError.reload")}
</Button> </Button>
+13 -1
View File
@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
import { RouteTransition } from "./RouteTransition"; import { RouteTransition } from "./RouteTransition";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>; type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
type RouteSkeletonVariant = "auth" | "content" | "shell";
type RouteSkeletonVariant = "auth"
| "content"
| "shell";
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props; const { variant } = props;
@@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
role="status" role="status"
> >
<Skeleton height={32} width="45%" /> <Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" /> <Skeleton height={36} radius="sm" />
</Stack> </Stack>
</Container> </Container>
@@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
> >
<Group justify="space-between" p="md"> <Group justify="space-between" p="md">
<Skeleton height={28} width={160} /> <Skeleton height={28} width={160} />
<Skeleton height={28} width={96} /> <Skeleton height={28} width={96} />
</Group> </Group>
<Container fluid p="md"> <Container fluid p="md">
<Skeleton height={20} width="30%" /> <Skeleton height={20} width="30%" />
<Stack mt="lg"> <Stack mt="lg">
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </Stack>
</Container> </Container>
@@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
return ( return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status"> <Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </Stack>
); );
@@ -61,6 +72,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const LazyPage: FC<LazyPageProps> = (props) => { 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"
+6 -1
View File
@@ -11,6 +11,7 @@ const Protected: FC<PropsWithChildren> = (props) => {
const { ready, user } = 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}</>
@@ -30,13 +31,17 @@ const Public: FC<PropsWithChildren> = (props) => {
const location = useLocation(); const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null | { returnTo?: string })?.returnTo;
const requested = (location.state as null
| { returnTo?: string })?.returnTo;
const returnTo const returnTo
= typeof requested === "string" = typeof requested === "string"
&& requested.startsWith("/") && requested.startsWith("/")
&& !requested.startsWith("//") && !requested.startsWith("//")
? requested ? requested
: "/accounts"; : "/accounts";
return user ? <Navigate replace to={returnTo} /> : <>{children}</>; return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
}; };
+6 -4
View File
@@ -11,16 +11,18 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const transition = {
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut" as const,
};
return ( return (
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>
<m.div <m.div
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
initial={!reduceMotion && { opacity: 0 }} initial={!reduceMotion && { opacity: 0 }}
key={pathname} key={pathname}
transition={{ transition={transition}
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut",
}}
> >
{children} {children}
</m.div> </m.div>
@@ -8,19 +8,24 @@ import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = { type AccountBreadcrumbsProps = {
accountId: string; accountId: string;
section?: "events" | "income" | "operations"; section?: "events"
| "income"
| "operations";
}; };
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => { 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" },
section ? { label: accountName, to: accountPath } : { label: accountName }, section ? { label: accountName, to: accountPath } : { label: accountName },
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useAccountScopeSwitchVm } from "./AccountScopeSwitch.vm";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const { accountId, data, onChange, t } = useAccountScopeSwitchVm(props);
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={onChange}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch };
@@ -1,6 +1,3 @@
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";
@@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string }; type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => { function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) {
const { accountId, section } = props; const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation(); const { search } = useLocation();
const data = [ const data = [
{ label: t("accounts.all"), value: "all" }, { label: t("accounts.all"), value: "all" },
...(accounts ?? []).map(account => ({ ...(accounts ?? []).map(account => ({
@@ -21,27 +19,23 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
value: account.id, value: account.id,
})), })),
]; ];
if (data.every(item => item.value !== accountId)) if (data.every(item => item.value !== accountId))
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId }); data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
search: filters.toString(),
});
}}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch }; const onChange: (value: null | string) => void = (value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section]
.filter(Boolean)
.join("/"),
search: filters.toString(),
});
};
return { accountId, data, onChange, t };
}
export { useAccountScopeSwitchVm };
@@ -16,6 +16,7 @@ function renderBreadcrumbs(
accounts?: { id: string; name: string }[], accounts?: { id: string; name: string }[],
) { ) {
useBrokerageAccounts.mockReturnValue({ data: accounts }); useBrokerageAccounts.mockReturnValue({ data: accounts });
return render( return render(
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
@@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => {
"page", "page",
); );
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3"); expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
for (const separator of screen.getAllByText("→")) { for (const separator of screen.getAllByText("→")) {
expect(separator).toHaveAttribute("aria-hidden", "true"); expect(separator).toHaveAttribute("aria-hidden", "true");
} }
+13 -2
View File
@@ -5,7 +5,7 @@ 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/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs"; import { CardBreadcrumbs } from "./CardBreadcrumbs";
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const operations = useMatch({ const operations = useMatch({
end: true, end: true,
path: "/accounts/:accountId/operations", path: "/accounts/:accountId/operations",
}); });
const events = useMatch({ end: true, path: "/accounts/:accountId/events" }); const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" }); const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" }); const account = useMatch({ end: true, path: "/accounts/:accountId" });
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
accountId={operations.params.accountId ?? ""} accountId={operations.params.accountId ?? ""}
section="operations" section="operations"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={operations.params.accountId ?? ""} accountId={operations.params.accountId ?? ""}
section="operations" section="operations"
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
accountId={events.params.accountId ?? ""} accountId={events.params.accountId ?? ""}
section="events" section="events"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={events.params.accountId ?? ""} accountId={events.params.accountId ?? ""}
section="events" section="events"
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
accountId={income.params.accountId ?? ""} accountId={income.params.accountId ?? ""}
section="income" section="income"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={income.params.accountId ?? ""} accountId={income.params.accountId ?? ""}
section="income" section="income"
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} /> <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} /> <AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack> </Stack>
); );
@@ -80,7 +86,12 @@ 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") }]} />;
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
return settings
? (
<BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
)
: undefined;
}; };
export { AppBreadcrumbs }; export { AppBreadcrumbs };
@@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string }; type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => { const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier); const { data } = useBond(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
@@ -7,11 +7,13 @@ import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css"; import classes from "./AppBreadcrumbs.module.css";
type BreadcrumbTrailProps = { items: Crumb[] }; type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string }; type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => { const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props; const { items } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Breadcrumbs <Breadcrumbs
aria-label={t("breadcrumbs.label")} aria-label={t("breadcrumbs.label")}
@@ -7,9 +7,11 @@ import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string }; type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => { const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier); const { data } = useBankCard(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
@@ -7,9 +7,11 @@ import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string }; type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => { const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier); const { data } = useStock(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
-178
View File
@@ -30,31 +30,6 @@
} }
} }
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.stocksIcon { .stocksIcon {
color: var(--mantine-color-green-8); color: var(--mantine-color-green-8);
} }
@@ -67,163 +42,10 @@
color: var(--mantine-color-gray-7); color: var(--mantine-color-gray-7);
} }
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.navLink { .navLink {
transition: background-color 180ms ease-in-out; transition: background-color 180ms ease-in-out;
} }
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
} }
@media (max-width: 47.99375em) { @media (max-width: 47.99375em) {
+33 -80
View File
@@ -1,11 +1,16 @@
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core"; import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import {
BondsIcon,
CardsIcon,
PortfolioIcon,
SettingsIcon,
StocksIcon,
} from "@wealthledger/icons";
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 classes from "./AppShell.module.css"; import classes from "./AppShell.module.css";
type AppShellProps = { type AppShellProps = {
@@ -16,80 +21,34 @@ const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props; const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const items = [ const items = [
{ {
icon: ( icon: <PortfolioIcon size={20} />,
<>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</>
),
iconClassName: classes.portfolioIcon, iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"), label: t("navigation.accounts"),
to: "/accounts", to: "/accounts",
}, },
{ {
icon: ( icon: <CardsIcon size={20} />,
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</>
),
iconClassName: classes.cardsIcon, iconClassName: classes.cardsIcon,
label: t("navigation.cards"), label: t("navigation.cards"),
to: "/cards", to: "/cards",
}, },
{ {
icon: ( icon: <StocksIcon size={20} />,
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
iconClassName: classes.stocksIcon, iconClassName: classes.stocksIcon,
label: t("navigation.stocks"), label: t("navigation.stocks"),
to: "/stocks", to: "/stocks",
}, },
{ {
icon: ( icon: <BondsIcon size={20} />,
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</>
),
iconClassName: classes.bondsIcon, iconClassName: classes.bondsIcon,
label: t("navigation.bonds"), label: t("navigation.bonds"),
to: "/bonds", to: "/bonds",
}, },
{ {
icon: ( icon: <SettingsIcon size={20} />,
<>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
iconClassName: classes.settingsIcon, iconClassName: classes.settingsIcon,
label: t("navigation.settings"), label: t("navigation.settings"),
to: "/settings", to: "/settings",
@@ -105,6 +64,7 @@ const AppShell: FC<AppShellProps> = (props) => {
<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
@@ -117,45 +77,38 @@ const AppShell: FC<AppShellProps> = (props) => {
<span <span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`} className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
> >
<svg {item.icon}
aria-hidden="true"
fill="none"
focusable="false"
height={20}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={20}
>
{item.icon}
</svg>
</span> </span>
)} )}
to={item.to} to={item.to}
/> />
))} ))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}> <MantineAppShell.Main className={classes.main}>
<div className={classes.content}> <div className={classes.content}>
{contentAboveOutlet} {contentAboveOutlet}
<Outlet /> <Outlet />
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}> <nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map(item => ( {items.map((item) => {
<RouterNavLink const appearanceClass = pathname.startsWith(item.to)
className={ ? classes.active
(pathname.startsWith(item.to)) ? (classes.active) : undefined : undefined;
}
key={item.to} return (
to={item.to} <RouterNavLink
> className={appearanceClass}
{item.label} key={item.to}
</RouterNavLink> to={item.to}
))} >
{item.label}
</RouterNavLink>
);
})}
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
+44 -8
View File
@@ -19,8 +19,10 @@ import {
useBankSummary, useBankSummary,
} from "./queries"; } from "./queries";
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
const card: BankCard
= {
archivedAt: null, archivedAt: null,
balance: { currency: "RUB", value: "100" }, balance: { currency: "RUB", value: "100" },
bankName: null, bankName: null,
@@ -31,8 +33,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-09-01", openingDate: "2026-09-01",
paymentSystem: null, paymentSystem: null,
version: 1, version: 1,
} };
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
const transfer: CreateBankOperation = { const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" }, amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a", cardId: "a",
@@ -55,6 +58,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
http.get("*/api/v1/bank-cards", ({ request }) => { http.get("*/api/v1/bank-cards", ({ request }) => {
const key = new URL(request.url).pathname + new URL(request.url).search; const key = new URL(request.url).pathname + new URL(request.url).search;
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
return HttpResponse.json([card]); return HttpResponse.json([card]);
}), }),
http.get("*/api/v1/bank-cards/*", ({ request }) => { http.get("*/api/v1/bank-cards/*", ({ request }) => {
@@ -84,7 +88,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
page: 1, page: 1,
total: 0, total: 0,
}); });
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
return url.pathname.includes("/operations/")
? HttpResponse.json({ ...transfer, version: 1 })
: HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}), }),
]; ];
} }
@@ -94,15 +101,18 @@ function useReadModels() {
const a = useBankCard("a"); const a = useBankCard("a");
const b = useBankCard("b"); const b = useBankCard("b");
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20, limit: 20,
month: "2026-09", month: "2026-09",
page: 2, page: 2,
}); });
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(), mutations: useBankMutations(),
reads: [ reads: [
@@ -134,6 +144,7 @@ describe("banking API", () => {
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("month")).toBe("2026-09");
if (isFail && 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({
hasNext: page === 1, hasNext: page === 1,
items: [{ ...transfer, id: String(page) }], items: [{ ...transfer, id: String(page) }],
@@ -142,10 +153,12 @@ describe("banking API", () => {
}); });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => 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([
@@ -153,10 +166,12 @@ describe("banking API", () => {
"2", "2",
]); ]);
isFail = true; isFail = true;
const { result: failedResult } = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(failedResult.current.isError).toBe(true)); await waitFor(() => expect(failedResult.current.isError).toBe(true));
expect(failedResult.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
@@ -164,15 +179,15 @@ describe("banking API", () => {
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 () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use(...readHandlers(counts)); server.use(...readHandlers(counts));
const { result } = renderHook(useReadModels, { const { result } = renderHook(useReadModels, {
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?limit=20&month=2026-09&page=2", "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
); );
@@ -184,10 +199,13 @@ describe("banking API", () => {
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(() => { const refresh = vi.fn(() => {
token = "renewed"; token = "renewed";
return Promise.resolve(true); return Promise.resolve(true);
}); });
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh }); configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = []; const bodies: unknown[] = [];
const headers: (null | string)[] = []; const headers: (null | string)[] = [];
@@ -195,14 +213,17 @@ describe("banking API", () => {
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());
headers.push(request.headers.get("Authorization")); headers.push(request.headers.get("Authorization"));
return bodies.length === 1 return bodies.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json({ ...transfer, version: 1 }); : HttpResponse.json({ ...transfer, version: 1 });
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
@@ -214,14 +235,17 @@ describe("banking API", () => {
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());
return bodies.length === 1 return bodies.length === 1
? HttpResponse.error() ? HttpResponse.error()
: HttpResponse.json({ ...transfer, version: 1 }); : HttpResponse.json({ ...transfer, version: 1 });
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(async () => { await act(async () => {
await expect( await expect(
result.current.createOperation.mutateAsync(transfer), result.current.createOperation.mutateAsync(transfer),
@@ -239,13 +263,16 @@ describe("banking API", () => {
server.use( server.use(
http.post("*/api/v1/bank-cards/operations", async () => { http.post("*/api/v1/bank-cards/operations", async () => {
await pending; await pending;
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }); return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ mutations: useBankMutations(), query: useBankCard("a") }), () => ({ 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));
let saving: Promise<unknown>; let saving: Promise<unknown>;
act(() => { act(() => {
@@ -310,10 +337,12 @@ describe("banking API", () => {
HttpResponse.json({ code }, { status: 409 }), HttpResponse.json({ code }, { status: 409 }),
), ),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }), () => ({ card: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.card.data?.version).toBe(1)); await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => { await act(async () => {
await expect( await expect(
@@ -337,6 +366,7 @@ describe("banking API", () => {
const body = await request.json(); const body = await request.json();
requests.push(body); requests.push(body);
name = "Groceries"; name = "Groceries";
return HttpResponse.json({ return HttpResponse.json({
id: "food", id: "food",
isDefault: true, isDefault: true,
@@ -348,14 +378,17 @@ describe("banking API", () => {
"*/api/v1/bank-cards/operations/original", "*/api/v1/bank-cards/operations/original",
async ({ request }) => { async ({ request }) => {
requests.push(await request.json()); requests.push(await request.json());
return HttpResponse.json({ ...transfer, version: 2 }); return HttpResponse.json({ ...transfer, version: 2 });
}, },
), ),
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => { http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
requests.push(new URL(request.url).searchParams.get("version")); requests.push(new URL(request.url).searchParams.get("version"));
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ () => ({
categories: useBankCategories(), categories: useBankCategories(),
@@ -363,6 +396,7 @@ describe("banking API", () => {
}), }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0].name).toBe("Food"),
); );
@@ -406,9 +440,11 @@ describe("banking API", () => {
), ),
); );
const { queryClient, wrapper } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const { rerender, result } = 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"));
await apiClient.PUT("/api/v1/tbank-connection", { await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "synthetic" }, body: { token: "synthetic" },
+22 -3
View File
@@ -1,6 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import {
apiClient,
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
} from "@/shared/api";
import type { import type {
CreateBankCard, CreateBankCard,
@@ -17,21 +22,27 @@ async function mutationData<T>(
const { data, error } = await request; const { data, error } = await request;
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
return data; return data;
} }
function useBankMutations() { function useBankMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey }) => { predicate: ({ queryKey }) => {
const [method, path] = queryKey; const [method, path] = queryKey;
return method === "get"
return (
method === "get"
&& typeof path === "string" && typeof path === "string"
&& (path === "/api/v1/bank-cards" && (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/")); || path.startsWith("/api/v1/bank-cards/"))
);
}, },
}); });
const options = { const options = {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
@@ -49,11 +60,13 @@ function useBankMutations() {
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
} as const; } as const;
const cardCreation = 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: ({ body, id }: { body: UpdateBankCard; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
@@ -64,11 +77,13 @@ function useBankMutations() {
}), }),
), ),
}); });
const operationCreation = 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: ({ body, id }: { body: UpdateBankOperation; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
@@ -79,6 +94,7 @@ function useBankMutations() {
}), }),
), ),
}); });
const operationDeletion = useMutation({ const operationDeletion = useMutation({
...options, ...options,
mutationFn: ({ id, version }: { id: string; version: number }) => mutationFn: ({ id, version }: { id: string; version: number }) =>
@@ -88,11 +104,13 @@ function useBankMutations() {
}), }),
), ),
}); });
const categoryCreation = 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: ({ body, id }: { body: UpdateExpenseCategory; id: string }) => mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
@@ -103,6 +121,7 @@ function useBankMutations() {
}), }),
), ),
}); });
return { return {
createCard: cardCreation, createCard: cardCreation,
createCategory: categoryCreation, createCategory: categoryCreation,
+25 -4
View File
@@ -9,24 +9,34 @@ 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 CreateBankCard type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["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"];
@@ -41,12 +51,13 @@ function sortBankSummary(data: BankSummary): BankSummary {
if (left === null || right === null) if (left === null || right === null)
throw new Error("Invalid bank summary amount"); throw new Error("Invalid bank summary amount");
if (left > right) return -1; if (left > right) return -1;
return left < right ? 1 : 0; return left < right ? 1 : 0;
}), }),
}; };
} }
function useBankCard(id: string | undefined) { function useBankCard(id?: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", {
params: { path: { id: id ?? "" } }, params: { path: { id: id ?? "" } },
@@ -83,7 +94,7 @@ function useBankExpenses(
}); });
} }
function useBankOperation(id: string | undefined) { function useBankOperation(id?: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
@@ -105,6 +116,7 @@ function useBankOperations(
}, },
) { ) {
const isAll = query.limit === "all"; const isAll = query.limit === "all";
const parameters = { const parameters = {
path: { id: id ?? "" }, path: { id: id ?? "" },
query: { query: {
@@ -113,11 +125,13 @@ function useBankOperations(
page: isAll ? 1 : query.page, page: isAll ? 1 : query.page,
}, },
}; };
const options = apiQueryClient.queryOptions( const options = apiQueryClient.queryOptions(
"get", "get",
"/api/v1/bank-cards/{id}/operations", "/api/v1/bank-cards/{id}/operations",
{ params: parameters }, { params: parameters },
); );
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({ return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))), enabled: Boolean(id && (query.month || (query.from && query.to))),
@@ -126,6 +140,7 @@ function useBankOperations(
previousQuery, previousQuery,
) => { ) => {
const previous = previousQuery?.queryKey[2]?.params; const previous = previousQuery?.queryKey[2]?.params;
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id && previous?.path?.id === id
@@ -138,6 +153,7 @@ function useBankOperations(
}, },
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const items: BankOperation[] = []; const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) { for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET( const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations", "/api/v1/bank-cards/{id}/operations",
@@ -146,6 +162,7 @@ function useBankOperations(
signal, signal,
}, },
); );
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
if (!data) throw new Error("Missing bank operations response"); if (!data) throw new Error("Missing bank operations response");
@@ -154,18 +171,22 @@ function useBankOperations(
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 }; if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
} }
}, },
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey, queryKey: isAll
? ([...options.queryKey, "all"] as const)
: options.queryKey,
}); });
} }
function useBankSummary( function useBankSummary(
period: string | { from: string; to: string }, period: string
| { from: string; to: string },
cardId?: string, cardId?: string,
) { ) {
const query const query
= typeof period === "string" = typeof period === "string"
? { cardId, month: period } ? { cardId, month: period }
: { ...period, cardId }; : { ...period, cardId };
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query }, params: { query },
@@ -28,6 +28,7 @@ it("orders displayed expenses descending, preserving cents, ties and source data
net: money("0"), net: money("0"),
refunds: 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"],
); );
+5 -10
View File
@@ -10,21 +10,16 @@ export {
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
} from "./api/queries"; } from "./api/queries";
export { export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
bankDateInputValue, export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
bankDateSchema,
bankMoneyValue, export { bankOperationAppearance } from "./model/operation-appearance";
bankToday,
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { bankOperationKinds } from "./model/operation-kinds"; export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period"; export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback"; export { BankFeedback } from "./ui/BankFeedback";
export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
export { CategoryManager } from "./ui/CategoryManager/CategoryManager"; export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
export { useBankFormFocus } from "./ui/useBankFormFocus"; export { useBankFormFocus } from "./ui/useBankFormFocus";
@@ -0,0 +1,30 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/queries";
const MAX_CATEGORY_NAME_LENGTH = 80;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({
name: z
.string()
.trim()
.refine(
value => value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
{ error: validationMessage("bank.validation.category") },
)
.refine(
value =>
!categories?.some(
c =>
c.id !== editingId
&& c.name.toLocaleLowerCase("ru-RU")
=== value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
),
});
}
export { categorySchema };
@@ -1,14 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
bankCardSchema, import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankMonth,
bankToday,
positiveMoneySchema,
} from "./form-validation";
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
describe("bank form input", () => { describe("bank form input", () => {
@@ -16,6 +9,7 @@ describe("bank form input", () => {
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe( expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
true, true,
); );
for (const invalid of [ for (const invalid of [
"92233720368547758.08", "92233720368547758.08",
"1.001", "1.001",
@@ -26,7 +20,9 @@ describe("bank form input", () => {
]) { ]) {
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false); expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
} }
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20"); expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
const values = { const values = {
bankName: "", bankName: "",
lastFour: "", lastFour: "",
@@ -34,6 +30,7 @@ describe("bank form input", () => {
openingBalance: "-92233720368547758.08", openingBalance: "-92233720368547758.08",
openingDate: "2026-01-01", openingDate: "2026-01-01",
}; };
expect(bankCardSchema.safeParse(values).success).toBe(true); expect(bankCardSchema.safeParse(values).success).toBe(true);
expect( expect(
bankCardSchema.safeParse({ bankCardSchema.safeParse({
@@ -62,6 +59,7 @@ it("converts local date input without accepting impossible or unfinished dates",
expect( expect(
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success, bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
).toBe(true); ).toBe(true);
for (const value of ["31.02.2026", "07.08.", ""]) { for (const value of ["31.02.2026", "07.08.", ""]) {
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe( expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
false, false,
+13 -79
View File
@@ -1,28 +1,25 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 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;
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 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) { function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value ? value
: bankToday().slice(0, ISO_MONTH_LENGTH); : bankToday().slice(0, ISO_MONTH_LENGTH);
} }
function bankToday() { function bankToday() {
return new Intl.DateTimeFormat("en-CA", { return new Intl.DateTimeFormat("en-CA", {
day: "2-digit", day: "2-digit",
@@ -31,84 +28,21 @@ function bankToday() {
year: "numeric", year: "numeric",
}).format(new Date()); }).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+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null; if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT); // eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null.
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return ( return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "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 MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
const moneySchema = z.string().refine( export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
(value) => {
const cents = moneyCents(value);
return (
cents !== null
&& cents >= MIN_BALANCE_KOPECKS
&& cents <= MAX_BALANCE_KOPECKS
);
},
{ error: validationMessage("bank.validation.money") },
);
const positiveMoneySchema = moneySchema.refine(
(value) => {
const cents = moneyCents(value);
return cents === null || cents > 0n;
},
{ error: validationMessage("bank.validation.positive") },
);
const bankDateSchema = z
.string()
.refine(
value =>
/^\d{4}-\d{2}-\d{2}$/.test(value)
&& value >= "0001-01-01"
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) === value
&& value <= bankToday(),
{ error: validationMessage("bank.validation.date") },
);
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
.string()
.trim()
.min(1, validationMessage("bank.validation.name")(undefined))
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined),
),
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
});
type BankCardValues = z.infer<typeof bankCardSchema>;
export {
bankCardSchema,
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankMonth,
bankToday,
moneyCents,
positiveMoneySchema,
};
@@ -0,0 +1,81 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { bankToday, moneyCents } from "./form-validation";
const ISO_DATE_LENGTH = 10;
const MAX_CARD_NAME_LENGTH = 100;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
const moneySchema = z.string().refine(
(value) => {
const cents = moneyCents(value);
return (
cents !== null
&& cents >= MIN_BALANCE_KOPECKS
&& cents <= MAX_BALANCE_KOPECKS
);
},
{ error: validationMessage("bank.validation.money") },
);
const positiveMoneySchema = moneySchema.refine(
(value) => {
const cents = moneyCents(value);
return cents === null || cents > 0n;
},
{ error: validationMessage("bank.validation.positive") },
);
const bankDateSchema = z
.string()
.refine(
value =>
/^\d{4}-\d{2}-\d{2}$/.test(value)
&& value >= "0001-01-01"
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH)
=== value
&& value <= bankToday(),
{ error: validationMessage("bank.validation.date") },
);
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
.string()
.trim()
.min(1, validationMessage("bank.validation.name")(undefined))
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined),
),
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
});
type BankCardValues = z.infer<typeof bankCardSchema>;
export {
bankCardSchema,
type BankCardValues,
bankDateSchema,
positiveMoneySchema,
};
@@ -1,124 +1,132 @@
import type { OperationIconProps } from "@wealthledger/icons";
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/queries";
type OperationIcon = { animation?: string; color: string; path: string }; type OperationIcon = {
animation?: string;
color: string;
variant: OperationIconProps["variant"];
};
const categoryIcons: Record<string, OperationIcon> = { const categoryIcons: Record<string, OperationIcon> = {
"алкоголь": { "алкоголь": {
animation: "wine", animation: "wine",
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", variant: "wine",
}, },
"вклады": { "вклады": {
animation: "deposit", animation: "deposit",
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", variant: "deposit",
}, },
"жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" }, "жилье": { color: "orange", variant: "home" },
"здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" }, "здоровье": { color: "red", variant: "medical" },
"инвестиции и накопления": { "инвестиции и накопления": {
animation: "growth", animation: "growth",
color: "green", color: "green",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5", variant: "growth",
}, },
"кафе": { "кафе": {
animation: "coffee", animation: "coffee",
color: "orange", 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", variant: "coffee",
}, },
"образование": { "образование": {
color: "blue", color: "blue",
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9", variant: "education",
}, },
"переводы": { "переводы": {
animation: "transfer", animation: "transfer",
color: "blue", color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4", variant: "transfer",
}, },
"погашение кредита": { "погашение кредита": {
animation: "repayment", animation: "repayment",
color: "orange", color: "orange",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4", variant: "repayment",
}, },
"подписки": { "подписки": {
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", variant: "subscriptions",
}, },
"покупки": { "покупки": {
color: "grape", color: "grape",
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2", variant: "shopping",
}, },
"продукты": { "продукты": {
animation: "basket", animation: "basket",
color: "green", 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", variant: "basket",
}, },
"путешествия": { "путешествия": {
color: "blue", color: "blue",
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z", variant: "travel",
}, },
"развлечения": { "развлечения": {
color: "grape", color: "grape",
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2", variant: "music",
}, },
"связь и интернет": { "связь и интернет": {
animation: "wifi", animation: "wifi",
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", variant: "wifi",
}, },
"снятие наличных": { "снятие наличных": {
color: "green", color: "green",
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4", variant: "cashWithdrawal",
}, },
"ставки": { "ставки": {
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", variant: "dice",
}, },
"страхование": { "страхование": {
color: "green", color: "green",
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6", variant: "shield",
}, },
"табак": { "табак": {
color: "orange", color: "orange",
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4", variant: "smoking",
}, },
"такси": { "такси": {
color: "yellow", color: "yellow",
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2", variant: "taxi",
}, },
"транспорт": { "транспорт": {
animation: "car", animation: "car",
color: "blue", 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", variant: "car",
}, },
"хостинг и ии-сервисы": { "хостинг и ии-сервисы": {
animation: "server", animation: "server",
color: "grape", color: "grape",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3", variant: "server",
}, },
"цифровые покупки": { "цифровые покупки": {
color: "blue", color: "blue",
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3", variant: "download",
}, },
}; };
const otherIcon: OperationIcon = { const otherIcon: OperationIcon = {
animation: "grid", animation: "grid",
color: "gray", color: "gray",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z", variant: "grid",
}; };
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" }, refund: { color: "teal", variant: "refund" },
topup: { topup: {
animation: "topup", animation: "topup",
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", variant: "topup",
}, },
transfer: { transfer: {
animation: "transfer", animation: "transfer",
color: "blue", color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4", variant: "transfer",
}, },
}; };
@@ -126,12 +134,18 @@ function bankOperationAppearance(
kind: BankOperation["kind"], kind: BankOperation["kind"],
categoryName?: null | string, 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]; if (kind !== "expense")
// eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
return operationIcons[kind];
const name = categoryName const name = categoryName
?.trim() ?.trim()
.toLocaleLowerCase("ru-RU") .toLocaleLowerCase("ru-RU")
.replaceAll("ё", "е"); .replaceAll("ё", "е");
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;
return name && Object.hasOwn(categoryIcons, name)
? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
: otherIcon;
} }
export { bankOperationAppearance }; export { bankOperationAppearance };
@@ -17,6 +17,7 @@ it.each([
it("rejects zero year consistently in legacy and explicit periods", () => { it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z")); vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) { for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({ expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01", from: "2026-09-01",
+3 -2
View File
@@ -1,15 +1,16 @@
import { z } from "zod"; import { calendarDate } from "@/shared/lib";
import { bankMonth } from "./form-validation"; import { bankMonth } from "./form-validation";
const calendarDate = z.iso.date();
const ISO_DATE_LENGTH = 10; const ISO_DATE_LENGTH = 10;
function bankPeriod(parameters: URLSearchParams) { function bankPeriod(parameters: URLSearchParams) {
const month = bankMonth(parameters.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 = parameters.get("from"); const from = parameters.get("from");
const to = parameters.get("to"); const to = parameters.get("to");
return from return from
&& to && to
&& calendarDate.safeParse(from).success && calendarDate.safeParse(from).success
@@ -4,53 +4,66 @@ import { SimpleGrid } from "@mantine/core";
import { BankCardFace } from "./BankCardFace"; import { BankCardFace } from "./BankCardFace";
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: { const meta
card: { = {
archivedAt: null, args: {
balance: { currency: "RUB", value: "128450.75" }, card: {
bankName: "Сбербанк", archivedAt: null,
id: "fictional", balance: { currency: "RUB", value: "128450.75" },
lastFour: "4829", bankName: "Сбербанк",
name: "Для повседневных покупок", id: "fictional",
openingBalance: { currency: "RUB", value: "0" }, lastFour: "4829",
openingDate: "2026-01-01", name: "Для повседневных покупок",
paymentSystem: "mir", openingBalance: { currency: "RUB", value: "0" },
version: 1, openingDate: "2026-01-01",
paymentSystem: "mir",
version: 1,
},
}, },
}, component: BankCardFace,
component: BankCardFace, tags: ["autodocs"],
tags: ["autodocs"], title: "Product/BankCards/CardAppearance",
title: "Product/BankCards/CardAppearance", } satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankCardFace>;
}
/* 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: arguments_ => ( render: arguments_ => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}> <SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null].map( {[
(bankName, index) => ( "Сбербанк",
<BankCardFace "Т-Банк",
card={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ "Альфа-Банк",
{ "ВТБ",
...arguments_.card, // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
bankName, null,
paymentSystem: ].map((bankName, index) => (
/* 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] <BankCardFace
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
card={
{
...arguments_.card,
bankName,
paymentSystem:
// eslint-disable-next-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, ?? null,
} }
/* eslint-enable unicorn/no-null */)} /* eslint-enable unicorn/no-null */
key={bankName ?? "unknown"} }
/> key={bankName ?? "unknown"}
), />
)} ))}
</SimpleGrid> </SimpleGrid>
), ),
}; };
export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ export const MissingDetails: Story
= {
args: { args: {
card: { card: {
...meta.args.card, ...meta.args.card,
@@ -59,8 +72,8 @@ export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixtu
paymentSystem: null, paymentSystem: null,
}, },
}, },
} };
/* eslint-enable unicorn/no-null */); /* eslint-enable unicorn/no-null */
export const LongArchived: Story = { export const LongArchived: Story = {
args: { args: {
card: { card: {
@@ -1,5 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { ContactlessIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
@@ -9,51 +10,46 @@ 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 };
const BankCardFace: FC<BankCardFaceProps> = (props) => { 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 = bankTone(bank); const tone = bankTone(bank);
return ( return (
<div className={classes.face} data-tone={tone}> <div className={classes.face} data-tone={tone}>
<div className={classes.top}> <div className={classes.top}>
<span className={classes.bank}>{card.bankName || t("bank.card")}</span> <span className={classes.bank}>{card.bankName || t("bank.card")}</span>
{card.archivedAt && ( {card.archivedAt && (
<span className={classes.archive}>{t("bank.archived")}</span> <span className={classes.archive}>{t("bank.archived")}</span>
)} )}
</div> </div>
<div aria-hidden="true" className={classes.hardware}> <div aria-hidden="true" className={classes.hardware}>
<span className={classes.chip} /> <span className={classes.chip} />
<svg
aria-hidden="true" <ContactlessIcon height="30" viewBox="0 0 24 30" />
fill="none"
height="30"
viewBox="0 0 24 30"
width="24"
>
<path
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"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
</div> </div>
<div
className={classes.number} <div className={classes.number}>
>
{`•••• •••• •••• ${card.lastFour || "••••"}`} {`•••• •••• •••• ${card.lastFour || "••••"}`}
</div> </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>
<div className={classes.balance}>{formatMoney(card.balance)}</div> <div className={classes.balance}>{formatMoney(card.balance)}</div>
</div> </div>
{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 aria-hidden="true" className={classes.circles} /> <span aria-hidden="true" className={classes.circles} />
)} )}
{t(`bank.paymentSystems.${card.paymentSystem}`)} {t(`bank.paymentSystems.${card.paymentSystem}`)}
</span> </span>
)} )}
@@ -66,6 +62,7 @@ function bankTone(bank: string) {
if (/сбер|sber/.test(bank)) return "green"; if (/сбер|sber/.test(bank)) return "green";
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow"; if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
if (/альфа|alfa/.test(bank)) return "red"; if (/альфа|alfa/.test(bank)) return "red";
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite"; return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
} }
@@ -0,0 +1,76 @@
import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useBankCardFieldsVm() {
const {
control,
formState: { errors },
register,
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange: (value: null | string) => void = value =>
paymentSystemField.onChange(value ?? "");
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange: (value: null | string) => void = value =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register,
t,
};
}
export { useBankCardFieldsVm };
@@ -2,7 +2,7 @@ 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.schema";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
@@ -20,6 +20,7 @@ const meta = {
openingDate: "2026-08-01", openingDate: "2026-08-01",
}, },
}); });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<Story /> <Story />
@@ -38,8 +39,11 @@ const meta = {
tags: ["autodocs"], tags: ["autodocs"],
title: "Product/BankCards/CardFields", 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 = {
args: { archived: true }, args: { archived: true },
@@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm"; import { BankCardForm } from "./BankCardForm";
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
const card: BankCard
= {
archivedAt: null, archivedAt: null,
balance: { currency: "RUB", value: "50" }, balance: { currency: "RUB", value: "50" },
bankName: "Банк", bankName: "Банк",
@@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01", openingDate: "2026-01-01",
paymentSystem: null, paymentSystem: null,
version: 1, version: 1,
} };
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
function setup(value: BankCard) { function setup(value: BankCard) {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
@@ -41,8 +44,10 @@ function setup(value: BankCard) {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
return close; return close;
} }
describe("BankCardForm", () => { describe("BankCardForm", () => {
it("waits for the exit transition before notifying the parent", async () => { it("waits for the exit transition before notifying the parent", async () => {
const close = setup(card); const close = setup(card);
@@ -64,20 +69,23 @@ describe("BankCardForm", () => {
server.use( server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => { http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json(card); return HttpResponse.json(card);
}), }),
); );
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((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(body).toEqual(
{ {
bankName: null, bankName: null,
lastFour: null, lastFour: null,
name: "Основная", name: "Основная",
paymentSystem: null, paymentSystem: null,
version: 1, version: 1,
} },
/* eslint-enable unicorn/no-null */)); /* 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" });
@@ -92,6 +100,7 @@ describe("BankCardForm", () => {
server.use( server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => { http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" }); return HttpResponse.json({ ...card, paymentSystem: "visa" });
}), }),
); );
@@ -132,6 +141,7 @@ describe("BankCardForm", () => {
const body = (await request.json()) as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>;
bodies.push(body); bodies.push(body);
version = 2; version = 2;
return body.version === 1 return body.version === 1
? HttpResponse.json( ? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" }, { code: "BANK_VERSION_CONFLICT" },
@@ -146,9 +156,11 @@ describe("BankCardForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/); await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
const adopt = await screen.findByRole("button", { const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2", name: "Использовать актуальную версию 2",
}); });
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик"); expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt); fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
@@ -1,121 +1,61 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react"; import { type FC } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations"; import { type BankCard } from "../../api/queries";
import { type BankCard, useBankCard } from "../../api/queries";
import {
bankCardSchema,
type BankCardValues,
bankMoneyValue,
bankToday,
} from "../../model/form-validation";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css"; import classes from "../BankForms.module.css";
import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm";
type BankCardFormProps = { card?: BankCard; onClose: () => void }; type BankCardFormProps = { card?: BankCard; onClose: () => void };
const BankCardForm: FC<BankCardFormProps> = (props) => { const BankCardForm: FC<BankCardFormProps> = (props) => {
useBankFormFocus(); const {
const [opened, setOpened] = useState(true); dialogTitle,
const { t } = useTranslation(); error,
const [intent] = useState(() => crypto.randomUUID()); isPending,
const [version, setVersion] = useState(props.card?.version ?? 1); latest,
const [error, setError] = useState<unknown>(); methods,
const [latest, setLatest] = useState<BankCard>(); onAdoptVersion,
const fresh = useBankCard(props.card?.id); onCancel,
const mutation = useBankMutations(); onReloadRecord,
const methods = useForm<BankCardValues>({ onRequestClose,
defaultValues: { onSubmit,
bankName: props.card?.bankName ?? "", opened,
lastFour: props.card?.lastFour ?? "", t,
name: props.card?.name ?? "", } = useBankCardFormVm(props);
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
},
resolver: zodResolver(bankCardSchema),
});
const isPending
= !opened
|| methods.formState.isSubmitting
|| (fresh.isFetching && Boolean(error));
return ( return (
<Modal <Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }} closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending} closeOnClickOutside={!isPending}
closeOnEscape={!isPending} closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)} onClose={onRequestClose}
onExitTransitionEnd={props.onClose} onExitTransitionEnd={props.onClose}
opened={opened} opened={opened}
title={t(props.card ? "bank.editCard" : "bank.addCard")} title={dialogTitle}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form onSubmit={onSubmit}>
onSubmit={methods.handleSubmit(async (values) => {
setError(undefined);
try {
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,
paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
body: {
...metadata,
...(!props.card.archivedAt && financial),
version,
},
id: props.card.id,
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
})}
>
<fieldset className={classes.fieldset} disabled={isPending}> <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 onClick={onReloadRecord} variant="default">
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
variant="default"
>
{t("bank.reloadRecord")} {t("bank.reloadRecord")}
</Button> </Button>
)} )}
</> </>
))} )}
{latest && ( {latest && (
<> <>
<Text> <Text>
@@ -123,23 +63,19 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`, details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
})} })}
</Text> </Text>
<Button
onClick={() => { <Button onClick={onAdoptVersion} variant="default">
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
variant="default"
>
{t("bank.adoptVersion", { version: latest.version })} {t("bank.adoptVersion", { version: latest.version })}
</Button> </Button>
</> </>
)} )}
<Group> <Group>
<Button loading={isPending} type="submit"> <Button loading={isPending} type="submit">
{t("bank.save")} {t("bank.save")}
</Button> </Button>
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onCancel} variant="default">
{t("bank.cancel")} {t("bank.cancel")}
</Button> </Button>
</Group> </Group>
@@ -2,24 +2,28 @@ 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 { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { import { bankToday } from "../../model/form-validation";
type BankCardValues, import { useBankCardFieldsVm } from "./BankCardFields.vm";
bankDateInputValue,
bankDateSchema,
bankToday,
} from "../../model/form-validation";
type BankCardFieldsProps = { archived: boolean }; type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => { const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const { const {
control, dateParser,
formState: { errors }, errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register, register,
} = useFormContext<BankCardValues>(); t,
const { t } = useTranslation(); } = useBankCardFieldsVm();
return ( return (
<Stack> <Stack>
<TextInput <TextInput
@@ -28,37 +32,36 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("name")} {...register("name")}
error={errors.name?.message} error={errors.name?.message}
/> />
<TextInput <TextInput
label={t("bank.bankName")} label={t("bank.bankName")}
{...register("bankName")} {...register("bankName")}
error={errors.bankName?.message} error={errors.bankName?.message}
/> />
<TextInput <TextInput
inputMode="numeric" inputMode="numeric"
label={t("bank.lastFour")} label={t("bank.lastFour")}
{...register("lastFour")} {...register("lastFour")}
error={errors.lastFour?.message} error={errors.lastFour?.message}
/> />
<Controller
control={control} <Select
name="paymentSystem" {...paymentSystemField}
render={({ field }) => ( clearable
<Select comboboxProps={{ withinPortal: false }}
{...field} data={paymentSystems}
clearable error={errors.paymentSystem?.message}
comboboxProps={{ withinPortal: false }} label={t("bank.paymentSystem")}
data={["mir", "visa", "mastercard"].map(value => ({ onChange={onPaymentSystemChange}
label: t(`bank.paymentSystems.${value}`), placeholder={t("bank.paymentSystemUnknown")}
value, value={
}))} paymentSystemField.value
error={errors.paymentSystem?.message} // eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
label={t("bank.paymentSystem")} || null
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
inputMode="decimal" inputMode="decimal"
label={t("bank.openingBalance")} label={t("bank.openingBalance")}
@@ -67,43 +70,31 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("openingBalance")} {...register("openingBalance")}
error={errors.openingBalance?.message} error={errors.openingBalance?.message}
/> />
<Controller
control={control} <DateInput
name="openingDate" {...openingDateField}
render={({ field }) => ( dateParser={dateParser}
<DateInput error={errors.openingDate?.message}
{...field} fixOnBlur={false}
dateParser={value => label={t("bank.openingDate")}
bankDateSchema.safeParse(bankDateInputValue(value)).success maxDate={bankToday()}
? bankDateInputValue(value) onBlur={onOpeningDateBlur}
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} onChange={onOpeningDateChange}
error={errors.openingDate?.message} onInput={onOpeningDateInput}
fixOnBlur={false} popoverProps={{
label={t("bank.openingDate")} middlewares: { shift: { crossAxis: true } },
maxDate={bankToday()} withinPortal: false,
onBlur={(event) => { }}
field.onChange(bankDateInputValue(event.currentTarget.value)); readOnly={props.archived}
field.onBlur(); required
}} value={openingDateValue}
onChange={value => field.onChange(value ?? "")} valueFormat="DD.MM.YYYY"
onInput={event =>
field.onChange(bankDateInputValue(event.currentTarget.value))}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
readOnly={props.archived}
required
value={
(bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined
}
valueFormat="DD.MM.YYYY"
/>
)}
/> />
<Text c="dimmed" size="sm"> <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>}
</Stack> </Stack>
); );
@@ -0,0 +1,127 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
function useBankCardFormVm(props: BankCardFormProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [intent] = useState(() => crypto.randomUUID());
const [version, setVersion] = useState(props.card?.version ?? 1);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations();
const methods = useForm<BankCardValues>({
defaultValues: {
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
name: props.card?.name ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
},
resolver: zodResolver(bankCardSchema),
});
const isPending
= !opened
|| methods.formState.isSubmitting
|| (fresh.isFetching && Boolean(error));
const onRequestClose = () => !isPending && setOpened(false);
const dialogTitle = t(props.card ? "bank.editCard" : "bank.addCard");
const onSubmit = methods.handleSubmit(async (values) => {
setError(undefined);
try {
const metadata = {
bankName:
values.bankName
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
lastFour:
values.lastFour
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
name: values.name,
paymentSystem:
values.paymentSystem
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
body: {
...metadata,
...(!props.card.archivedAt && financial),
version,
},
id: props.card.id,
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
});
const onReloadRecord = async () => {
const response = await fresh.refetch();
if (response.data && !response.error) setLatest(response.data);
else setError(response.error);
};
const onAdoptVersion = () => {
if (!latest) return;
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
};
const onCancel = () => setOpened(false);
return {
dialogTitle,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
t,
version,
};
}
export { useBankCardFormVm };
@@ -11,9 +11,11 @@ type BankFeedbackProps = {
loadingError?: boolean; loadingError?: boolean;
retry?: () => void; retry?: () => void;
}; };
const BankFeedback: FC<BankFeedbackProps> = (props) => { const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const code = apiErrorCode(props.error); const code = apiErrorCode(props.error);
return ( return (
<Alert color="orange" role="alert"> <Alert color="orange" role="alert">
<Group> <Group>
@@ -26,6 +28,7 @@ const BankFeedback: FC<BankFeedbackProps> = (props) => {
), ),
})} })}
</Text> </Text>
{props.retry && ( {props.retry && (
<Button onClick={props.retry} variant="default"> <Button onClick={props.retry} variant="default">
{t("bank.retry")} {t("bank.retry")}
@@ -1,9 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { EntryCard } from "@wealthledger/ui"; import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon"; import { bankOperationAppearance } from "../../model/operation-appearance";
const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
);
const meta = { const meta = {
args: { categoryName: "Продукты", kind: "expense" }, args: { categoryName: "Продукты", kind: "expense" },
@@ -11,8 +19,11 @@ const meta = {
tags: ["autodocs"], tags: ["autodocs"],
title: "Product/BankCards/OperationIcons", 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>;
export const Categories: Story = { export const Categories: Story = {
render: () => ( render: () => (
<Stack maw={640}> <Stack maw={640}>
@@ -55,6 +66,7 @@ export const Categories: Story = {
valueLabel="Расход" valueLabel="Расход"
/> />
))} ))}
<EntryCard <EntryCard
color="green" color="green"
icon={<BankOperationIcon kind="topup" />} icon={<BankOperationIcon kind="topup" />}
@@ -1,12 +1,21 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { OperationIcon } from "@wealthledger/icons";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { BankOperationIcon } from "./BankOperationIcon"; import { bankOperationAppearance } from "../../model/operation-appearance";
const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
);
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 categoryName="Жильё" kind="expense" />, <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. // 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();
@@ -15,16 +24,25 @@ it("matches normalized category names, falls back after renaming and distinguish
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />); 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", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
for (const categoryName of [
"Моя категория",
"constructor",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />); 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 categoryName="Жильё" kind={kind} />); 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. // 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");
@@ -34,8 +52,10 @@ it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render( const { container, rerender } = render(
<BankOperationIcon categoryName="Другое" kind="expense" />, <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. // 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 [
"Алкоголь", "Алкоголь",
"Вклады", "Вклады",
@@ -1,39 +0,0 @@
import type { FC } from "react";
import { bindIconAnimation } from "@/shared/lib";
import type { BankOperation } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankOperationIcon.module.css";
type BankOperationIconProps = {
categoryName?: null | string;
kind: BankOperation["kind"];
};
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
const icon = bankOperationAppearance(props.kind, props.categoryName);
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{icon.path.split(" M").map((part, index) => (
<path d={index ? `M${part}` : part} key={part} pathLength={1} />
))}
</svg>
);
};
export { BankOperationIcon };
@@ -1,29 +1,21 @@
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import { type FC, useState } from "react"; import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankPeriodPickerVm } from "./BankPeriodPicker.vm";
type BankPeriodPickerProps = { type BankPeriodPickerProps = {
onChange: (value: [null | string, null | string]) => void; onChange: (value: [null | string, null | string]) => void;
period: { from: string; to: string }; period: { from: string; to: string };
}; };
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => { const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
const { onChange, period } = props; const { onPeriodChange, t, value } = useBankPeriodPickerVm(props);
const { t } = useTranslation();
const scope = `${period.from}-${period.to}`;
const [draft, setDraft] = useState<{
scope: string;
value: [null | string, null | string];
}>();
const value: [null | string, null | string]
= draft?.scope === scope ? draft.value : [period.from, period.to];
return ( return (
<DatePickerInput <DatePickerInput
allowSingleDateInRange allowSingleDateInRange
label={t("bank.period")} label={t("bank.period")}
onChange={(next) => { onChange={onPeriodChange}
setDraft({ scope, value: next });
onChange(next);
}}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
returnFocus: true, returnFocus: true,
@@ -0,0 +1,30 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
type BankPeriodPickerProps = {
onChange: (value: [null | string, null | string]) => void;
period: { from: string; to: string };
};
function useBankPeriodPickerVm(props: BankPeriodPickerProps) {
const { onChange, period } = props;
const { t } = useTranslation();
const scope = `${period.from}-${period.to}`;
const [draft, setDraft] = useState<{
scope: string;
value: [null | string, null | string];
}>();
const value: [null | string, null | string]
= draft?.scope === scope ? draft.value : [period.from, period.to];
const onPeriodChange = (next: [null | string, null | string]) => {
setDraft({ scope, value: next });
onChange(next);
};
return { onChange, onPeriodChange, t, value };
}
export { useBankPeriodPickerVm };
@@ -3,69 +3,75 @@ 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 = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
args: {
data: { const meta
cardId: null, = {
categories: [ args: {
{ data: {
categoryId: "food", cardId: null,
categoryName: "Продукты", categories: [
expenses: money("3500"), {
net: money("2500"), categoryId: "food",
refunds: money("1000"), categoryName: "Продукты",
}, expenses: money("3500"),
{ net: money("2500"),
categoryId: "cafe", refunds: money("1000"),
categoryName: "Кафе", },
expenses: money("2200"), {
net: money("2200"), categoryId: "cafe",
refunds: money("0"), categoryName: "Кафе",
}, expenses: money("2200"),
{ net: money("2200"),
categoryId: "taxi", refunds: money("0"),
categoryName: "Такси", },
expenses: money("1800"), {
net: money("1800"), categoryId: "taxi",
refunds: money("0"), categoryName: "Такси",
}, expenses: money("1800"),
{ net: money("1800"),
categoryId: "health", refunds: money("0"),
categoryName: "Здоровье", },
expenses: money("1000"), {
net: money("1000"), categoryId: "health",
refunds: money("0"), categoryName: "Здоровье",
}, expenses: money("1000"),
], net: money("1000"),
expenses: money("8500"), refunds: money("0"),
month: "2026-09", },
net: money("7500"), ],
refunds: money("1000"), expenses: money("8500"),
month: "2026-09",
net: money("7500"),
refunds: money("1000"),
},
error: false,
fetching: false,
loading: false,
retry: () => {},
}, },
error: false, component: BankSummaryView,
fetching: false, parameters: {
loading: false, docs: {
retry: () => {}, description: {
}, component:
component: BankSummaryView, "Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Один итог с учётом возвратов и компактные строки категорий. Отрицательные суммы сохраняют знак.",
parameters: { },
docs: {
description: {
component:
"Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Один итог с учётом возвратов и компактные строки категорий. Отрицательные суммы сохраняют знак.",
}, },
}, },
}, tags: ["autodocs"],
tags: ["autodocs"], title: "Product/BankCards/Summary",
title: "Product/BankCards/Summary", } satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankSummaryView>;
}
/* eslint-enable unicorn/no-null */) 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 = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ export const RefundMonth: Story
= {
args: { args: {
data: { data: {
cardId: null, cardId: null,
@@ -73,7 +79,7 @@ export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture
{ {
categoryId: "shopping", categoryId: "shopping",
categoryName: categoryName:
"Покупки с длинным пользовательским названием категории", "Покупки с длинным пользовательским названием категории",
expenses: money("0"), expenses: money("0"),
net: money("-600"), net: money("-600"),
refunds: money("600"), refunds: money("600"),
@@ -86,10 +92,11 @@ export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture
}, },
}, },
name: "Возврат покупки прошлого месяца", name: "Возврат покупки прошлого месяца",
} };
/* eslint-enable unicorn/no-null */); /* eslint-enable unicorn/no-null */
export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ export const Empty: Story
= {
args: { args: {
data: { data: {
cardId: null, cardId: null,
@@ -101,8 +108,8 @@ export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
}, },
}, },
name: "Известные нули", name: "Известные нули",
} };
/* eslint-enable unicorn/no-null */); /* eslint-enable unicorn/no-null */
export const Loading: Story = { export const Loading: Story = {
args: { data: undefined, loading: true }, args: { data: undefined, loading: true },
name: "Начальная загрузка", name: "Начальная загрузка",
@@ -115,8 +122,9 @@ export const CachedError: Story = {
args: { error: true }, args: { error: true },
name: "Сохранённые суммы при ошибке обновления", name: "Сохранённые суммы при ошибке обновления",
}; };
export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ export const LongAmounts: Story
= {
args: { args: {
data: { data: {
cardId: null, cardId: null,
@@ -136,5 +144,5 @@ export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture
}, },
}, },
name: "Точный итог длиннее предела отдельного ввода", name: "Точный итог длиннее предела отдельного ввода",
} };
/* eslint-enable unicorn/no-null */); /* eslint-enable unicorn/no-null */
@@ -1,6 +1,7 @@
import type { CSSProperties, FC } from "react"; 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 { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,7 +9,7 @@ import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries"; import type { BankSummary } from "../../api/queries";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon"; import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2; const CATEGORY_COLUMNS = 2;
@@ -21,77 +22,93 @@ type BankSummaryViewProps = {
loading: boolean; loading: boolean;
retry: () => void; retry: () => void;
}; };
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
const { t } = useTranslation();
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}>
<OperationIcon
{...bankOperationAppearance("expense", category.categoryName)}
/>
<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>
);
}
};
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 aria-busy={props.loading || props.fetching} gap="md"> <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")}
</Title> </Title>
{props.data && ( {props.data && (
<Text className={classes.total}> <Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })} {formatMoney(props.data.net, { withZeroCents: true })}
</Text> </Text>
)} )}
</div> </div>
{renderCategories()}
<BankSummaryViewCategories {...props} />
{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 onClick={props.retry} variant="default"> <Button onClick={props.retry} variant="default">
{t("bank.retry")} {t("bank.retry")}
</Button> </Button>
</Group> </Group>
)} )}
{props.data && props.fetching && ( {props.data && props.fetching && (
<Text role="status">{t("bank.refreshing")}</Text> <Text role="status">{t("bank.refreshing")}</Text>
)} )}
@@ -23,18 +23,23 @@ describe("CategoryManager", () => {
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
const categories = [ const categories = [
{ id: "a", isDefault: false, name: "Первая", version: 1 }, { id: "a", isDefault: false, name: "Первая", version: 1 },
{ id: "b", isDefault: false, name: "Вторая", version: 4 }, { id: "b", isDefault: false, name: "Вторая", version: 4 },
]; ];
let reads = 0; let reads = 0;
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>(); const { promise: reloaded, resolve: release }
= Promise.withResolvers<void>();
const patches: { body: unknown; id: unknown }[] = []; const patches: { body: unknown; id: 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
@@ -51,6 +56,7 @@ describe("CategoryManager", () => {
"*/api/v1/bank-cards/categories/:id", "*/api/v1/bank-cards/categories/:id",
async ({ params, request }) => { async ({ params, request }) => {
patches.push({ body: await request.json(), id: params.id }); 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,7 +73,11 @@ describe("CategoryManager", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
const renameButtons = await screen.findAllByRole("button", {
name: "Переименовать",
});
fireEvent.click(renameButtons[0]); fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click( fireEvent.click(
@@ -122,6 +132,7 @@ describe("CategoryManager", () => {
), ),
http.patch("*/api/v1/bank-cards/categories/a", () => { http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2; version = 2;
return ++writes === 1 return ++writes === 1
? HttpResponse.json( ? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" }, { code: "BANK_VERSION_CONFLICT" },
@@ -163,12 +174,14 @@ describe("CategoryManager", () => {
clear: () => {}, clear: () => {},
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
let category = { let category = {
id: "default", id: "default",
isDefault: true, isDefault: true,
name: "Другое", name: "Другое",
version: 1, version: 1,
}; };
let creates = 0; let creates = 0;
let body: unknown; let body: unknown;
server.use( server.use(
@@ -177,6 +190,7 @@ describe("CategoryManager", () => {
), ),
http.post("*/api/v1/bank-cards/categories", () => { http.post("*/api/v1/bank-cards/categories", () => {
creates++; creates++;
return HttpResponse.json({}); return HttpResponse.json({});
}), }),
http.patch( http.patch(
@@ -184,6 +198,7 @@ describe("CategoryManager", () => {
async ({ request }) => { async ({ request }) => {
body = await request.json(); body = await request.json();
category = { ...category, name: "Остальное", version: 2 }; category = { ...category, name: "Остальное", version: 2 };
return HttpResponse.json(category); return HttpResponse.json(category);
}, },
), ),
@@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { import {
Badge, Badge,
Button, Button,
@@ -8,218 +7,148 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { type FC, useRef, useState } from "react"; import { OperationIcon } from "@wealthledger/icons";
import { import { type FC } from "react";
Controller, import { FormProvider, useController, useFormContext } from "react-hook-form";
FormProvider,
useForm,
useFormContext,
} from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css"; import classes from "../BankForms.module.css";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon"; import { useCategoryManagerVm } from "./CategoryManager.vm";
import { useBankFormFocus } from "../useBankFormFocus";
const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryManagerProps = { type CategoryManagerProps = {
onClose: () => void; onClose: () => void;
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
}; };
type CategoryValues = { name: string }; type CategoryValues = { name: string };
const CategoryFields: FC = () => { const CategoryFields: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
control, control,
formState: { errors }, formState: { errors },
} = useFormContext<CategoryValues>(); } = useFormContext<CategoryValues>();
const { field: nameField } = useController({ control, name: "name" });
return ( return (
<Controller <TextInput
control={control} label={t("bank.categoryName")}
name="name" required
render={({ field }) => ( {...nameField}
<TextInput error={errors.name?.message}
label={t("bank.categoryName")}
required
{...field}
error={errors.name?.message}
/>
)}
/> />
); );
}; };
const CategoryManager: FC<CategoryManagerProps> = (props) => { const CategoryManager: FC<CategoryManagerProps> = (props) => {
useBankFormFocus(); const {
const [opened, setOpened] = useState(true); categories,
const { t } = useTranslation(); categoryRows,
const categories = useBankCategories(); editing,
const mutation = useBankMutations(); error,
const [editing, setEditing] = useState<ExpenseCategory>(); isPending,
const editSessionRef = useRef(0); latest,
const [error, setError] = useState<unknown>(); methods,
const [latest, setLatest] = useState<ExpenseCategory>(); onAdoptVersion,
const schema = z.object({ onCancel,
name: z onDismiss,
.string() onReloadRecord,
.trim() onRequestClose,
.refine( onSubmit,
value => opened,
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, retryCategories,
{ error: validationMessage("bank.validation.category") }, t,
) } = useCategoryManagerVm(props);
.refine(
value =>
!categories.data?.some(
c =>
c.id !== editing?.id
&& c.name.toLocaleLowerCase("ru-RU")
=== value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
),
});
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
const isPending = !opened || methods.formState.isSubmitting;
return ( return (
<Modal <Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }} closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending} closeOnClickOutside={!isPending}
closeOnEscape={!isPending} closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)} onClose={onRequestClose}
onExitTransitionEnd={props.onClose} onExitTransitionEnd={props.onClose}
opened={opened} opened={opened}
title={t("bank.categories")} title={t("bank.categories")}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form onSubmit={onSubmit}>
onSubmit={(event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
try {
if (editing)
await mutation.updateCategory.mutateAsync({
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
const result = await mutation.createCategory.mutateAsync({
name: values.name,
});
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
}
catch (error_) {
setError(error_);
}
})(event);
}}
>
<fieldset className={classes.fieldset} disabled={isPending}> <fieldset className={classes.fieldset} disabled={isPending}>
<Stack> <Stack>
{categories.data?.map(category => ( {categoryRows?.map((category) => {
<Group justify="space-between" key={category.id}> return (
<Group gap="xs"> <Group justify="space-between" key={category.id}>
<BankOperationIcon <Group gap="xs">
categoryName={category.name} <OperationIcon
kind="expense" {...bankOperationAppearance("expense", category.name)}
/> />
<Text>{category.name}</Text>
{category.isDefault && ( <Text>{category.name}</Text>
<Badge>{t("bank.defaultCategory")}</Badge>
)} {category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
</Group>
<Button onClick={category.onRename} variant="subtle">
{t("bank.rename")}
</Button>
</Group> </Group>
<Button );
onClick={() => { })}
editSessionRef.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
setLatest(undefined);
}}
variant="subtle"
>
{t("bank.rename")}
</Button>
</Group>
))}
{categories.isLoading && ( {categories.isLoading && (
<Text role="status">{t("bank.loading")}</Text> <Text role="status">{t("bank.loading")}</Text>
)} )}
{categories.isError && ( {categories.isError && (
<BankFeedback <BankFeedback
cached={Boolean(categories.data)} cached={Boolean(categories.data)}
error={categories.error} error={categories.error}
loadingError loadingError
retry={() => void categories.refetch()} retry={retryCategories}
/> />
)} )}
<CategoryFields /> <CategoryFields />
{!!(error) && ((
{!!error && (
<> <>
<BankFeedback error={error} /> <BankFeedback error={error} />
{editing && ( {editing && (
<Button <Button onClick={onReloadRecord} variant="default">
onClick={async () => {
const session = editSessionRef.current;
const response = await categories.refetch();
if (session !== editSessionRef.current) return;
if (response.error) setError(response.error);
else
setLatest(
response.data?.find(c => c.id === editing.id),
);
}}
variant="default"
>
{t("bank.reloadRecord")} {t("bank.reloadRecord")}
</Button> </Button>
)} )}
</> </>
))} )}
{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>
<Button
onClick={() => { <Button onClick={onAdoptVersion}>
setEditing(latest);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })} {t("bank.adoptVersion", { version: latest.version })}
</Button> </Button>
</> </>
)} )}
<Group> <Group>
<Button loading={isPending} type="submit"> <Button loading={isPending} type="submit">
{t(editing ? "bank.save" : "bank.addCategory")} {t(editing ? "bank.save" : "bank.addCategory")}
</Button> </Button>
{editing && ( {editing && (
<Button <Button onClick={onCancel} variant="default">
onClick={() => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
variant="default"
>
{t("bank.cancel")} {t("bank.cancel")}
</Button> </Button>
)} )}
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onDismiss} variant="default">
{t("bank.close")} {t("bank.close")}
</Button> </Button>
</Group> </Group>
@@ -0,0 +1,128 @@
import type { ComponentProps } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const schema = categorySchema(categories.data, editing?.id);
const methods = useForm<CategoryValues>({
defaultValues: { name: "" },
resolver: zodResolver(schema),
});
const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false);
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
try {
if (editing)
await mutation.updateCategory.mutateAsync({
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
const result = await mutation.createCategory.mutateAsync({
name: values.name,
});
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
}
catch (error_) {
setError(error_);
}
})(event);
};
const retryCategories = () => void categories.refetch();
const onReloadRecord = async () => {
if (!editing) return;
const session = editSessionRef.current;
const response = await categories.refetch();
if (session !== editSessionRef.current) return;
if (response.error) setError(response.error);
else setLatest(response.data?.find(c => c.id === editing.id));
};
const onAdoptVersion = () => {
setEditing(latest);
setLatest(undefined);
setError(undefined);
};
const onCancel = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
};
const onDismiss = () => setOpened(false);
const categoryRows = categories.data?.map((category) => {
const onRename = () => {
editSessionRef.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
setLatest(undefined);
};
return { ...category, onRename };
});
return {
categories,
categoryRows,
editing,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onDismiss,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
retryCategories,
t,
};
}
export { useCategoryManagerVm };
+10 -1
View File
@@ -18,6 +18,7 @@ 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(
@@ -28,6 +29,7 @@ function useBond(identifier: string) {
...bondQueryOptions(identifier), ...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid, enabled: Boolean(identifier) && isUid,
}); });
const byTicker = useResourceQuery({ const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } }, params: { path: { ticker: identifier } },
@@ -35,6 +37,7 @@ function useBond(identifier: string) {
enabled: Boolean(identifier) && !isUid, enabled: Boolean(identifier) && !isUid,
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
@@ -47,6 +50,7 @@ function useBondCandles(uid: string, period: BondPeriod) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondCoupons(uid: string) { function useBondCoupons(uid: string) {
return useResourceQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
@@ -57,6 +61,7 @@ function useBondCoupons(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondMetrics(uid: string) { function useBondMetrics(uid: string) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
@@ -66,6 +71,7 @@ function useBondMetrics(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondPosition(uid: string) { function useBondPosition(uid: string) {
return useResourceQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
@@ -76,6 +82,7 @@ function useBondPosition(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondQuote(uid: string) { function useBondQuote(uid: string) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
@@ -85,6 +92,7 @@ function useBondQuote(uid: string) {
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondSearch(query: string) { function useBondSearch(query: string) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
@@ -95,7 +103,8 @@ function useBondSearch(query: string) {
}); });
} }
export { type BondPeriod, export {
type BondPeriod,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
+2 -1
View File
@@ -1,4 +1,5 @@
export { type BondPeriod, export {
type BondPeriod,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,

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