refactor: enforce frontend readability and UI boundaries #43

Merged
ksv741 merged 2 commits from codex/frontend-view-guardrails into develop 2026-09-28 19:40:24 +03:00
287 changed files with 8558 additions and 4538 deletions
Showing only changes of commit ca0ad4f824 - Show all commits
+5 -4
View File
@@ -69,10 +69,11 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui`
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
`packages/icons`; package не зависит от UI, domain или motion.
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
Add/Edit/Delete; restore/category SVG остаются локальными.
Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
Приложение импортирует компоненты из `@wealthledger/icons` и подключает
`@wealthledger/icons/styles.css`. В `src` нет 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.
Общие Mantine/theme/i18n/Dates providers уже есть в
[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 ради каталога.
`play` через `storybook/test` нужен, когда interaction иллюстрирует собственный
+24
View File
@@ -17,6 +17,30 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [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 вызываются без условий и ранних выходов перед ними; custom hook нужен
+29 -11
View File
@@ -11,13 +11,11 @@ Mantine component и общую тему. Для выпадающего выбо
ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например,
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
components живут у feature/entity/page, даже при повторном использовании.
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
иконки из model сущности. SVG декоративны по умолчанию; доступное имя action
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
и доступное имя. Приложение и Storybook подключают CSS пакета.
Wrapper нужен для проектной ответственности, а не ради нового имени
для 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.module.css` | Локальные стили |
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
image fallback) и formatter одного переданного значения. Группировка/доли
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
View получает готовые данные и именованные действия. Container вызывает VM;
VM владеет запросами, формой, состоянием, обработчиками и derived values.
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
вызывает 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 выноси в
`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 создаёт
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
Во время submit блокируй все действия формы. Zod errors показывай у поля;
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
name: "stories-must-not-reach-application-data",
severity: "error",
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)/`,
Outdated
Review

можно ли упростить ? без использования String.raw ?

можно ли упростить ? без использования String.raw ?
reachable: true,
},
},
+4 -2
View File
@@ -1,12 +1,14 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@wealthledger/icons/styles.css";
import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "../src/shared/i18n";
import type { Preview } from "@storybook/react-vite";
import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
+11 -11
View File
@@ -262,14 +262,14 @@ Library build использует отдельный Vite config без app plu
Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены.
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
цвет наследуется через `currentColor`, SVG props можно передать явно.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
доступное имя задавайте кнопке. Standalone пример:
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`.
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
Restore/category SVG и предметный BankOperationIcon остаются в приложении.
Private `@wealthledger/icons` содержит все SVG приложения: действия,
навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
иконок. Category/kind mapping остаётся в model `entities/bank-card`.
Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
`strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
имя принадлежит действию. Для самостоятельного изображения передайте
`aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
Анимации сохраняют 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 tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
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([
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
"**/dist/**",
"dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]),
{
extends: [
@@ -35,32 +49,91 @@ export default defineConfig(
security.configs.recommended,
noUnsanitized.configs.recommended,
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}"],
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": ["error", {
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
objects: "always-multiline", tuples: "always-multiline",
}],
"@stylistic/comma-dangle": [
"error",
{
arrays: "always-multiline",
enums: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
generics: "ignore",
imports: "always-multiline",
objects: "always-multiline",
tuples: "always-multiline",
},
],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/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/no-tabs": ["error", { allowIndentationTabs: true }],
"@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.
"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.
"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.
"unicorn/no-null": ["error", { checkArguments: false }],
"unicorn/prefer-ternary": ["error", "only-single-line"],
},
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 },
},
{
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"],
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
{
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
extends: [
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 },
rules: {
"import-x/exports-last": "error",
@@ -95,10 +187,16 @@ export default defineConfig(
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": ["error", {
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
}],
"@typescript-eslint/no-magic-numbers": [
"error",
{
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"],
},
storybook.configs["flat/recommended"],
{
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: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
],
ignores: [
"**/*.test.{ts,tsx}",
"**/*.stories.tsx",
"src/test/**",
],
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [
"error",
{
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:
"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.",
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: "Name the derived prop value before JSX.",
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.",
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
message:
"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.",
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
message:
"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.",
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.",
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
message:
"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",
"import": "./dist/index.js"
}
},
"./styles.css": "./dist/index.css"
},
"sideEffects": false,
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "vite build && tsc -p tsconfig.json"
},
+5
View File
@@ -17,6 +17,7 @@ const meta = {
} satisfies Meta<typeof AddIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Decorative: Story = {
@@ -25,9 +26,11 @@ export const Decorative: Story = {
<button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} />
</button>
<button aria-label="Изменить пример" type="button">
<EditIcon size={18} />
</button>
<button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} />
</button>
@@ -39,7 +42,9 @@ export const Meaningful: Story = {
render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div>
),
+1
View File
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
<Icon />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(
// 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;
Outdated
Review

не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме

не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме
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({
matches: true,
} as MediaQueryList);
const { container } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</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.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
let finishFirst!: () => void;
let finishLast!: () => void;
const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>();
finishFirst = first.resolve;
finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }];
});
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target);
@@ -53,6 +58,7 @@ it("keeps playing after keyboard blur and releases cancelled animations", async
Category
</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.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
@@ -13,6 +13,7 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
delete icon.dataset.iconAnimating;
}
};
const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = "";
@@ -27,21 +28,25 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
release();
});
};
const enter = () => {
if (!matchMedia("(hover: hover)").matches) return;
isHovered = true;
start();
};
const leave = () => {
isHovered = false;
release();
};
const focus = () => {
isFocused
= target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible");
if (isFocused) start();
};
const blur = (event: FocusEvent) => {
if (
event.relatedTarget instanceof Node
@@ -51,10 +56,12 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
isFocused = false;
release();
};
target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur);
return () => {
isDisposed = true;
target.removeEventListener("mouseenter", enter);
+13
View File
@@ -1,4 +1,17 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
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 { 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";
+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 { size = "24", ...svgProps } = props;
return (
<svg
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 { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
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": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"lib": [
"ES2023",
"DOM"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
@@ -11,7 +14,12 @@
"emitDeclarationOnly": true,
"rootDir": "src",
"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>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {};
@@ -31,11 +31,13 @@ describe("EntryCard", () => {
value: "—",
valueLabel: "Неизвестно",
};
const { rerender } = render(
<MantineProvider>
<EntryCard {...props} />
</MantineProvider>,
);
// Decorative image has no accessible name; trigger its actual failure event.
const image = screen.queryByAltText("");
assert(image);
+21 -17
View File
@@ -13,24 +13,25 @@ type EntryCardProps = {
initial: string;
metadata: string;
title: string;
tone: "negative" | "neutral" | "positive";
tone: "negative"
| "neutral"
| "positive";
value: string;
valueLabel: string;
};
const EntryCard: FC<EntryCardProps> = (props) => {
return (
<Paper
className={classes.row}
style={
(props.color)
? ({
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
})
: undefined
const surfaceStyle = props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
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
alt=""
aria-hidden="true"
@@ -40,26 +41,29 @@ const EntryCard: FC<EntryCardProps> = (props) => {
>
{props.icon ?? props.initial}
</Avatar>
<Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}>
{props.title}
</Text>
<Text c="dimmed" size="sm">
{props.metadata}
</Text>
{props.details}
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>
{props.value}
</Text>
<div className={(props.actions) ? (classes.valueFooter) : undefined}>
<Text
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm"
>
<div className={valueContainerClass}>
<Text className={valueLabelClass} size="sm">
{props.valueLabel}
</Text>
{Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div>
)}
@@ -23,7 +23,9 @@ const meta = {
<Paper p="md" withBorder>
<LoadingSkeleton {...arguments_}>
<Skeleton h={24} w="60%" />
<Skeleton h={72} />
<Skeleton h={16} w="80%" />
</LoadingSkeleton>
</Paper>
@@ -33,5 +35,7 @@ const meta = {
} satisfies Meta<typeof LoadingSkeleton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" };
@@ -41,16 +41,17 @@ const meta = {
},
render: (arguments_) => {
const { onAction, withAction, ...stateProps } = arguments_;
return (
<StateView
{...stateProps}
action={
(withAction)
? ((
withAction
? (
<Button onClick={onAction} variant="default">
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
</Button>
))
)
: undefined
}
/>
@@ -63,6 +64,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = {
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>
<Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text>
{action}
</Stack>
</Paper>
+3 -1
View File
@@ -1,4 +1,3 @@
import "./styles.css";
import {
colorsTuple,
createTheme,
@@ -11,8 +10,11 @@ import {
} from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json";
import "./styles.css";
const { breakpoints, color, radius, shadows, spacing, typography }
= designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries(
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", () => {
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync(
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
"utf8",
);
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]
.replaceAll(/^ {10}/gm, "");
const double = String.raw`#!${process.execPath}
const fs = require('node:fs');
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');
} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
`;
try {
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.
@@ -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.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
}
const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], {
cwd: directory,
@@ -59,6 +67,7 @@ if (args.startsWith('manifest inspect')) {
...extra,
},
});
assert.notEqual(run().status, 0);
const retry = run();
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(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = nodePath.join(temporaryDirectory, "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");
@@ -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.
readFile(generatedPath, "utf8"),
]);
if (committed !== current) {
console.error(
"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 { 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(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tag_ = git(
"tag",
"--merged",
@@ -20,6 +22,7 @@ const tag_ = git(
)
.split("\n")
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(
version,
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 unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict";
import { globSync } from "node:fs";
import { test } from "node:test";
import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint();
for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
rule,
);
}
for (const file of [
"src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js",
@@ -117,9 +120,11 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"coverage/index.js",
])
assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx",
);
assert.equal(story.rules["import-x/no-default-export"][0], 0);
assert.ok(story.rules["storybook/default-exports"][0] > 0);
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 component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
@@ -147,12 +153,25 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
[vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
...storybook.configs["flat/recommended"]
.filter(config => config.rules)
.map(config => [
config,
config.name.endsWith("main-rules")
? ".storybook/main.ts"
: "src/shared/ui/Button.stories.tsx",
]),
]) {
const configs = [preset].flat();
const expected = Object.assign({}, ...configs.map(config => config.rules));
const expected = Object.assign(
{},
...configs.map(config => config.rules),
);
const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
@@ -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 () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath },
);
assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output);
@@ -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 () => {
const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
});
@@ -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 () => {
const eslint = new ESLint();
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 });
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 [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"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 });
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 [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"));
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 [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 marker = nodePath.join(cwd, "downstream");
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}`) =>
spawnSync("/bin/sh", ["-e", hook], {
cwd,
encoding: "utf8",
env: { ...process.env, PATH: path },
});
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin);
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.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
mode: 0o755,
});
writeFileSync(
nodePath.join(bin, command),
"#!/bin/sh\necho ran >> downstream\n",
{
mode: 0o755,
},
);
}
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.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
+76 -42
View File
@@ -28,8 +28,11 @@ const accounts = [
{ id: "a", 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,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" },
@@ -40,8 +43,9 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
quantity: "2",
ticker: "SBER",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const portfolio: Portfolio = {
cashBalances: [],
isStale: false,
@@ -50,8 +54,11 @@ const portfolio: Portfolio = {
structure: { currencies: [], unvaluedCategories: [] },
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,
category: "income",
commission: null,
@@ -67,11 +74,15 @@ const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixtur
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
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",
bucketGranularity: "day",
buckets: [],
@@ -105,17 +116,21 @@ const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture
},
],
yearComparison: null,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const CurrentLocation: FC = () => {
const location = useLocation();
return (
<output aria-label="Текущий путь">
{location.pathname}
{location.search}
</output>
);
};
function renderPage(path: string) {
const client = createTestQueryClient();
render(
@@ -124,12 +139,14 @@ function renderPage(path: string) {
<MemoryRouter initialEntries={[path]}>
<AuthProvider>
<AppRoutes />
<CurrentLocation />
</AuthProvider>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return client;
}
@@ -144,14 +161,17 @@ beforeEach(() => {
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource(
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...account,
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */))),
accounts.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account =>
({
...account,
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}),
/* eslint-enable unicorn/no-null */
),
),
),
),
@@ -162,15 +182,17 @@ beforeEach(() => {
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) =>
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,
hasNext: false,
items,
nextCursor: null,
observedAt: receipt.occurredAt,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
@@ -181,21 +203,24 @@ beforeEach(() => {
resource({
accountId: "all",
calculatedAt: receipt.occurredAt,
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
account,
date: "2026-09-15",
dateKind: "event",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
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",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}),
/* eslint-enable unicorn/no-null */
),
incomeTotalsByCurrency: [],
}),
),
@@ -235,7 +260,11 @@ describe("All accounts routes", () => {
);
for (const suffix of ["operations", "income", "events"])
expect(
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`),
screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument();
});
@@ -247,8 +276,10 @@ describe("All accounts routes", () => {
({ params, request }) => {
const url = new URL(request.url);
requests.push(url);
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,
hasNext: !url.searchParams.has("cursor"),
@@ -262,8 +293,9 @@ describe("All accounts routes", () => {
],
nextCursor: url.searchParams.has("cursor") ? null : "next",
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")),
).toBe(true),
);
const selected = requests.find(url =>
url.pathname.includes("/b/operations"),
);
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("categories")).toBe("income");
+6 -3
View File
@@ -62,6 +62,7 @@ describe("AppRoutes", () => {
server.use(
http.post("*/api/v1/auth/login", async ({ request }) => {
credentials = await request.json();
return HttpResponse.json({
accessToken: "login-token",
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/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,
categories: [],
@@ -111,8 +113,9 @@ describe("AppRoutes", () => {
month: "2026-08",
net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
);
renderRoutes("/cards?month=2026-08");
+50
View File
@@ -8,72 +8,104 @@ import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
return { default: CardPage };
});
const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage };
});
const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond");
return { default: BondPage };
});
const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage };
});
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const AppRoutes: FC = () => (
<Routes>
<Route element={<Navigate replace to="/accounts" />} path="/" />
<Route
element={(
<Public>
@@ -84,6 +116,7 @@ const AppRoutes: FC = () => (
)}
path="/login"
/>
<Route
element={(
<Public>
@@ -94,6 +127,7 @@ const AppRoutes: FC = () => (
)}
path="/register"
/>
<Route
element={(
<Protected>
@@ -111,6 +145,7 @@ const AppRoutes: FC = () => (
)}
path="/cards/:cardId"
/>
<Route
element={(
<LazyPage variant="content">
@@ -119,6 +154,7 @@ const AppRoutes: FC = () => (
)}
path="/cards"
/>
<Route
element={(
<LazyPage variant="content">
@@ -127,6 +163,7 @@ const AppRoutes: FC = () => (
)}
path="/bonds"
/>
<Route
element={(
<LazyPage variant="content">
@@ -135,6 +172,7 @@ const AppRoutes: FC = () => (
)}
path="/bonds/:bondId"
/>
<Route
element={(
<LazyPage variant="content">
@@ -143,6 +181,7 @@ const AppRoutes: FC = () => (
)}
path="/stocks"
/>
<Route
element={(
<LazyPage variant="content">
@@ -151,6 +190,7 @@ const AppRoutes: FC = () => (
)}
path="/stocks/:stockId"
/>
<Route
element={(
<LazyPage variant="content">
@@ -159,6 +199,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts"
/>
<Route
element={(
<LazyPage variant="content">
@@ -167,6 +208,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/all"
/>
<Route
element={(
<LazyPage variant="content">
@@ -175,6 +217,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/all/operations"
/>
<Route
element={(
<LazyPage variant="content">
@@ -183,6 +226,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/all/income"
/>
<Route
element={(
<LazyPage variant="content">
@@ -191,6 +235,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/all/events"
/>
<Route
element={(
<LazyPage variant="content">
@@ -199,6 +244,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/:accountId"
/>
<Route
element={(
<LazyPage variant="content">
@@ -207,6 +253,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/:accountId/events"
/>
<Route
element={(
<LazyPage variant="content">
@@ -215,6 +262,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/:accountId/income"
/>
<Route
element={(
<LazyPage variant="content">
@@ -223,6 +271,7 @@ const AppRoutes: FC = () => (
)}
path="/accounts/:accountId/operations"
/>
<Route
element={(
<LazyPage variant="content">
@@ -232,6 +281,7 @@ const AppRoutes: FC = () => (
path="/settings"
/>
</Route>
<Route
element={(
<LazyPage variant="content">
@@ -21,11 +21,13 @@ import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => {
const navigate = useNavigate();
return (
<>
<button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета
</button>
<button onClick={() => navigate(-1)} type="button">
Назад
</button>
@@ -39,6 +41,7 @@ const renderTitle = (path: string) =>
<MemoryRouter initialEntries={[path]}>
<AuthProvider>
<ProtectedDocumentTitle />
<Navigation />
</AuthProvider>
</MemoryRouter>
@@ -59,10 +62,13 @@ beforeEach(() => {
describe("document titles", () => {
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(
http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady;
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}),
);
@@ -102,6 +108,7 @@ describe("document titles", () => {
server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}),
);
+18 -1
View File
@@ -24,29 +24,36 @@ type AccountDocumentTitleProps = {
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBrokerageAccounts();
const accountName
= props.accountId === "all"
? t("accounts.all")
: (data?.find(account => account.id === props.accountId)?.name
?? t("account.title"));
let section = t("portfolio.title");
switch (props.section ?? "") {
case "income": {
section = t("income.title");
break;
}
case "operations": {
section = t("ledger.title");
break;
}
case "events": {
section = t("events.title");
break;
}
default: {
break;
}
}
return <DocumentTitle title={`${section} — ${accountName}`} />;
};
@@ -55,18 +62,21 @@ type InstrumentDocumentTitleProps = { identifier: string };
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBond(props.identifier);
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
};
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useStock(props.identifier);
return <DocumentTitle title={data?.name ?? t("stock.share")} />;
};
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBankCard(props.identifier);
return <DocumentTitle title={data?.name ?? t("bank.card")} />;
};
@@ -94,7 +104,14 @@ const ProtectedDocumentTitle: FC = () => {
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
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")} />
);
Review

для компонентов, которые помещаются в 1 строку не обязательно добавлять скобки

для компонентов, которые помещаются в 1 строку не обязательно добавлять скобки
};
export { ProtectedDocumentTitle };
+1
View File
@@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => {
return (
<>
<ProtectedDocumentTitle />
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
</>
);
@@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary";
const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ user: session.isAuthenticated ? {} : null }
/* eslint-enable unicorn/no-null */)),
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
useAuth: () =>
({ user: session.isAuthenticated ? {} : null }),
/* eslint-enable unicorn/no-null */
}));
const ThrowingRoute: FC = () => {
@@ -39,6 +40,7 @@ const RouteHarness: FC = () => {
<button onClick={() => navigate("/healthy")} type="button">
go healthy
</button>
<RouteErrorBoundary>
<HealthyRoute />
</RouteErrorBoundary>
@@ -62,10 +64,13 @@ describe("RouteErrorBoundary", () => {
async (path) => {
session.isAuthenticated = path === "/accounts";
let isFailing = true;
const RecoverableRoute: FC = () => {
if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
@@ -116,10 +121,12 @@ describe("RouteErrorBoundary", () => {
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => {
isIntercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(isIntercepted).toBe(false);
@@ -33,7 +33,13 @@ class Boundary extends Component<BoundaryProps, State> {
}
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 (
<Stack>
<Title order={1}>{t("routeError.title")}</Title>
<Text>{t("routeError.text")}</Text>
<Button component={Link} to={to}>
{t("routeError.action")}
</Button>
<Button component="a" href={to} variant="default">
{t("routeError.reload")}
</Button>
+13 -1
View File
@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
import { RouteTransition } from "./RouteTransition";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
type RouteSkeletonVariant = "auth" | "content" | "shell";
type RouteSkeletonVariant = "auth"
| "content"
| "shell";
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props;
@@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
role="status"
>
<Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" />
</Stack>
</Container>
@@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
>
<Group justify="space-between" p="md">
<Skeleton height={28} width={160} />
<Skeleton height={28} width={96} />
</Group>
<Container fluid p="md">
<Skeleton height={20} width="30%" />
<Stack mt="lg">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
</Container>
@@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
);
@@ -61,6 +72,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props;
return (
<Suspense fallback={<RouteSkeleton variant={variant} />}>
{variant === "shell"
+6 -1
View File
@@ -11,6 +11,7 @@ const Protected: FC<PropsWithChildren> = (props) => {
const { ready, user } = useAuth();
const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />;
return user
? (
<>{children}</>
@@ -30,13 +31,17 @@ const Public: FC<PropsWithChildren> = (props) => {
const location = useLocation();
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
= typeof requested === "string"
&& requested.startsWith("/")
&& !requested.startsWith("//")
? requested
: "/accounts";
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
};
+6 -4
View File
@@ -11,16 +11,18 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
const { pathname } = useLocation();
const reduceMotion = useReducedMotion();
const transition = {
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut" as const,
};
return (
<LazyMotion features={domAnimation}>
<m.div
animate={{ opacity: 1 }}
initial={!reduceMotion && { opacity: 0 }}
key={pathname}
transition={{
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut",
}}
transition={transition}
>
{children}
</m.div>
@@ -8,19 +8,24 @@ import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = {
accountId: string;
section?: "events" | "income" | "operations";
section?: "events"
| "income"
| "operations";
};
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName
= accountId === "all"
? t("accounts.all")
: (accounts?.find(account => account.id === accountId)?.name
?? t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`;
const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
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 { useLocation, useNavigate } from "react-router";
@@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const { search } = useLocation();
const data = [
{ label: t("accounts.all"), value: "all" },
...(accounts ?? []).map(account => ({
@@ -21,27 +19,23 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
value: account.id,
})),
];
if (data.every(item => item.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 }[],
) {
useBrokerageAccounts.mockReturnValue({ data: accounts });
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
@@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => {
"page",
);
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
for (const separator of screen.getAllByText("→")) {
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 { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs";
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
const operations = useMatch({
end: true,
path: "/accounts/:accountId/operations",
});
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" });
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
accountId={operations.params.accountId ?? ""}
section="operations"
/>
<AccountScopeSwitch
accountId={operations.params.accountId ?? ""}
section="operations"
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
@@ -80,7 +86,12 @@ const AppBreadcrumbs: FC = () => {
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (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 };
@@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
@@ -7,11 +7,13 @@ import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css";
type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation();
return (
<Breadcrumbs
aria-label={t("breadcrumbs.label")}
@@ -7,9 +7,11 @@ import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
@@ -7,9 +7,11 @@ import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
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 {
color: var(--mantine-color-green-8);
}
@@ -67,163 +42,10 @@
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) {
.navLink {
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) {
+33 -80
View File
@@ -1,11 +1,16 @@
import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import {
BondsIcon,
CardsIcon,
PortfolioIcon,
SettingsIcon,
StocksIcon,
} from "@wealthledger/icons";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
@@ -16,80 +21,34 @@ const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
const { pathname } = useLocation();
const items = [
{
icon: (
<>
<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"
/>
</>
),
icon: <PortfolioIcon size={20} />,
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
icon: (
<>
<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>
</>
),
icon: <CardsIcon size={20} />,
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
icon: (
<>
<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" />
</>
),
icon: <StocksIcon size={20} />,
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
icon: (
<>
<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>
</>
),
icon: <BondsIcon size={20} />,
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
icon: (
<>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
icon: <SettingsIcon size={20} />,
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
@@ -105,6 +64,7 @@ const AppShell: FC<AppShellProps> = (props) => {
<MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="sm">
{items.map(item => (
<NavLink
@@ -117,45 +77,38 @@ const AppShell: FC<AppShellProps> = (props) => {
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
<svg
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>
{item.icon}
</span>
)}
to={item.to}
/>
))}
</MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}>
<div className={classes.content}>
{contentAboveOutlet}
<Outlet />
</div>
</MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map(item => (
<RouterNavLink
className={
(pathname.startsWith(item.to)) ? (classes.active) : undefined
}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
))}
{items.map((item) => {
const appearanceClass = pathname.startsWith(item.to)
? classes.active
: undefined;
return (
<RouterNavLink
className={appearanceClass}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
</nav>
</MantineAppShell>
);
+44 -8
View File
@@ -19,8 +19,10 @@ import {
useBankSummary,
} 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. */
Review

слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило

слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило
const card: BankCard
= {
archivedAt: null,
balance: { currency: "RUB", value: "100" },
bankName: null,
@@ -31,8 +33,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-09-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a",
@@ -55,6 +58,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
http.get("*/api/v1/bank-cards", ({ request }) => {
const key = new URL(request.url).pathname + new URL(request.url).search;
counts.set(key, (counts.get(key) ?? 0) + 1);
return HttpResponse.json([card]);
}),
http.get("*/api/v1/bank-cards/*", ({ request }) => {
@@ -84,7 +88,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
page: 1,
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 b = useBankCard("b");
const categories = useBankCategories();
const history = useBankOperations("a", {
limit: 20,
month: "2026-09",
page: 2,
});
const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original");
return {
mutations: useBankMutations(),
reads: [
@@ -134,6 +144,7 @@ describe("banking API", () => {
expect(url.searchParams.get("month")).toBe("2026-09");
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({
hasNext: page === 1,
items: [{ ...transfer, id: String(page) }],
@@ -142,10 +153,12 @@ describe("banking API", () => {
});
}),
);
const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map(item => item.id)).toEqual([
@@ -153,10 +166,12 @@ describe("banking API", () => {
"2",
]);
isFail = true;
const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(failedResult.current.isError).toBe(true));
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 () => {
const counts = new Map<string, number>();
server.use(...readHandlers(counts));
const { result } = renderHook(useReadModels, {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
expect(counts.keys()).toContain(
"/api/v1/bank-cards?includeArchived=true",
);
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
expect(counts.keys()).toContain(
"/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 () => {
let token = "expired";
const refresh = vi.fn(() => {
token = "renewed";
return Promise.resolve(true);
});
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = [];
const headers: (null | string)[] = [];
@@ -195,14 +213,17 @@ describe("banking API", () => {
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
headers.push(request.headers.get("Authorization"));
return bodies.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json({ ...transfer, version: 1 });
}),
);
const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper,
});
await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
@@ -214,14 +235,17 @@ describe("banking API", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({ ...transfer, version: 1 });
}),
);
const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper,
});
await act(async () => {
await expect(
result.current.createOperation.mutateAsync(transfer),
@@ -239,13 +263,16 @@ describe("banking API", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
await pending;
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
}),
);
const { result } = renderHook(
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>;
act(() => {
@@ -310,10 +337,12 @@ describe("banking API", () => {
HttpResponse.json({ code }, { status: 409 }),
),
);
const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => {
await expect(
@@ -337,6 +366,7 @@ describe("banking API", () => {
const body = await request.json();
requests.push(body);
name = "Groceries";
return HttpResponse.json({
id: "food",
isDefault: true,
@@ -348,14 +378,17 @@ describe("banking API", () => {
"*/api/v1/bank-cards/operations/original",
async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json({ ...transfer, version: 2 });
},
),
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
requests.push(new URL(request.url).searchParams.get("version"));
return new HttpResponse(null, { status: 204 });
}),
);
const { result } = renderHook(
() => ({
categories: useBankCategories(),
@@ -363,6 +396,7 @@ describe("banking API", () => {
}),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"),
);
@@ -406,9 +440,11 @@ describe("banking API", () => {
),
);
const { queryClient, wrapper } = createQueryWrapper();
const { rerender, result } = renderHook(() => useBankCard("a"), {
wrapper,
});
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "synthetic" },
+22 -3
View File
@@ -1,6 +1,11 @@
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 {
CreateBankCard,
@@ -17,21 +22,27 @@ async function mutationData<T>(
const { data, error } = await request;
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
return data;
}
function useBankMutations() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({
predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return method === "get"
return (
method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"));
|| path.startsWith("/api/v1/bank-cards/"))
);
},
});
const options = {
onError: (error: unknown) => {
const errorCode = apiErrorCode(error);
@@ -49,11 +60,13 @@ function useBankMutations() {
onSuccess: invalidate,
retry: false,
} as const;
const cardCreation = useMutation({
...options,
mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
});
const updateCard = useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
@@ -64,11 +77,13 @@ function useBankMutations() {
}),
),
});
const operationCreation = useMutation({
...options,
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
const updateOperation = useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
@@ -79,6 +94,7 @@ function useBankMutations() {
}),
),
});
const operationDeletion = useMutation({
...options,
mutationFn: ({ id, version }: { id: string; version: number }) =>
@@ -88,11 +104,13 @@ function useBankMutations() {
}),
),
});
const categoryCreation = useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
const updateCategory = useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
@@ -103,6 +121,7 @@ function useBankMutations() {
}),
),
});
return {
createCard: cardCreation,
createCategory: categoryCreation,
+25 -4
View File
@@ -9,24 +9,34 @@ import { moneyCents } from "../model/form-validation";
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
@@ -41,12 +51,13 @@ function sortBankSummary(data: BankSummary): BankSummary {
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
if (left > right) return -1;
return left < right ? 1 : 0;
}),
};
}
function useBankCard(id: string | undefined) {
function useBankCard(id?: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", {
params: { path: { id: id ?? "" } },
@@ -83,7 +94,7 @@ function useBankExpenses(
});
}
function useBankOperation(id: string | undefined) {
function useBankOperation(id?: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
@@ -105,6 +116,7 @@ function useBankOperations(
},
) {
const isAll = query.limit === "all";
const parameters = {
path: { id: id ?? "" },
query: {
@@ -113,11 +125,13 @@ function useBankOperations(
page: isAll ? 1 : query.page,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params: parameters },
);
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))),
@@ -126,6 +140,7 @@ function useBankOperations(
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id
@@ -138,6 +153,7 @@ function useBankOperations(
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
@@ -146,6 +162,7 @@ function useBankOperations(
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
@@ -154,18 +171,22 @@ function useBankOperations(
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(
period: string | { from: string; to: string },
period: string
| { from: string; to: string },
cardId?: string,
) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
@@ -28,6 +28,7 @@ it("orders displayed expenses descending, preserving cents, ties and source data
net: money("0"),
refunds: money("0"),
};
expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
["3", "1", "2", "5", "4", "0"],
);
+5 -10
View File
@@ -10,21 +10,16 @@ export {
useBankOperations,
useBankSummary,
} from "./api/queries";
export {
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankToday,
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
export { bankOperationAppearance } from "./model/operation-appearance";
export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback";
export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon";
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
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 {
bankCardSchema,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankMonth,
bankToday,
positiveMoneySchema,
} from "./form-validation";
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
afterEach(() => vi.useRealTimers());
describe("bank form input", () => {
@@ -16,6 +9,7 @@ describe("bank form input", () => {
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
true,
);
for (const invalid of [
"92233720368547758.08",
"1.001",
@@ -26,7 +20,9 @@ describe("bank form input", () => {
]) {
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
}
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
const values = {
bankName: "",
lastFour: "",
@@ -34,6 +30,7 @@ describe("bank form input", () => {
openingBalance: "-92233720368547758.08",
openingDate: "2026-01-01",
};
expect(bankCardSchema.safeParse(values).success).toBe(true);
expect(
bankCardSchema.safeParse({
@@ -62,6 +59,7 @@ it("converts local date input without accepting impossible or unfinished dates",
expect(
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
).toBe(true);
for (const value of ["31.02.2026", "07.08.", ""]) {
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
false,
+13 -79
View File
@@ -1,28 +1,25 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
const ISO_DATE_LENGTH = 10;
const ISO_MONTH_LENGTH = 7;
const MAX_CARD_NAME_LENGTH = 100;
function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
@@ -31,84 +28,21 @@ function bankToday() {
year: "numeric",
}).format(new Date());
}
function moneyCents(value: string): bigint | null {
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;
const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT);
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
// 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 (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (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(
(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,
};
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
@@ -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";
type OperationIcon = { animation?: string; color: string; path: string };
type OperationIcon = {
animation?: string;
color: string;
variant: OperationIconProps["variant"];
};
const categoryIcons: Record<string, OperationIcon> = {
Review

эту логику нужно переработать, потому что svg должны быть целостными, а не собираться в рантайме
также ключи в виде русских слов это порнография

эту логику нужно переработать, потому что svg должны быть целостными, а не собираться в рантайме также ключи в виде русских слов это порнография
"алкоголь": {
animation: "wine",
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",
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: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" },
"жилье": { color: "orange", variant: "home" },
"здоровье": { color: "red", variant: "medical" },
"инвестиции и накопления": {
animation: "growth",
color: "green",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
variant: "growth",
},
"кафе": {
animation: "coffee",
color: "orange",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
variant: "coffee",
},
"образование": {
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",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
variant: "transfer",
},
"погашение кредита": {
animation: "repayment",
color: "orange",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
variant: "repayment",
},
"подписки": {
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",
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
variant: "shopping",
},
"продукты": {
animation: "basket",
color: "green",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
variant: "basket",
},
"путешествия": {
color: "blue",
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
variant: "travel",
},
"развлечения": {
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",
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",
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
variant: "cashWithdrawal",
},
"ставки": {
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",
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
variant: "shield",
},
"табак": {
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",
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
variant: "taxi",
},
"транспорт": {
animation: "car",
color: "blue",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
variant: "car",
},
"хостинг и ии-сервисы": {
animation: "server",
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",
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
variant: "download",
},
};
const otherIcon: OperationIcon = {
animation: "grid",
color: "gray",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
variant: "grid",
};
const operationIcons: Record<
Exclude<BankOperation["kind"], "expense">,
OperationIcon
> = {
refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" },
refund: { color: "teal", variant: "refund" },
topup: {
animation: "topup",
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: {
animation: "transfer",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
variant: "transfer",
},
};
@@ -126,12 +134,18 @@ function bankOperationAppearance(
kind: BankOperation["kind"],
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
?.trim()
.toLocaleLowerCase("ru-RU")
.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 };
@@ -17,6 +17,7 @@ it.each([
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
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";
const calendarDate = z.iso.date();
const ISO_DATE_LENGTH = 10;
function bankPeriod(parameters: URLSearchParams) {
const month = bankMonth(parameters.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = parameters.get("from");
const to = parameters.get("to");
return from
&& to
&& calendarDate.safeParse(from).success
@@ -4,53 +4,66 @@ import { SimpleGrid } from "@mantine/core";
import { BankCardFace } from "./BankCardFace";
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
card: {
archivedAt: null,
balance: { currency: "RUB", value: "128450.75" },
bankName: "Сбербанк",
id: "fictional",
lastFour: "4829",
name: "Для повседневных покупок",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: "mir",
version: 1,
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const meta
= {
args: {
card: {
archivedAt: null,
balance: { currency: "RUB", value: "128450.75" },
bankName: "Сбербанк",
id: "fictional",
lastFour: "4829",
name: "Для повседневных покупок",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: "mir",
version: 1,
},
},
},
component: BankCardFace,
tags: ["autodocs"],
title: "Product/BankCards/CardAppearance",
}
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankCardFace>;
component: BankCardFace,
tags: ["autodocs"],
title: "Product/BankCards/CardAppearance",
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankCardFace>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banks: Story = {
render: arguments_ => (
<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,
bankName,
paymentSystem:
/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ (["mir", "mastercard", "visa", "mir", null] as const)[index]
{[
"Сбербанк",
"Т-Банк",
"Альфа-Банк",
"ВТБ",
Review

названия банков должны быть вынесены в отдельный enum

названия банков должны быть вынесены в отдельный enum
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
].map((bankName, 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,
}
/* eslint-enable unicorn/no-null */)}
key={bankName ?? "unknown"}
/>
),
)}
}
/* eslint-enable unicorn/no-null */
}
key={bankName ?? "unknown"}
/>
))}
</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: {
card: {
...meta.args.card,
@@ -59,8 +72,8 @@ export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixtu
paymentSystem: null,
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const LongArchived: Story = {
args: {
card: {
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { ContactlessIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
@@ -9,51 +10,46 @@ import type { BankCard } from "../../api/queries";
import classes from "./BankCardFace.module.css";
type BankCardFaceProps = { card: BankCard };
const BankCardFace: FC<BankCardFaceProps> = (props) => {
const { card } = props;
const { t } = useTranslation();
const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
const tone = bankTone(bank);
return (
<div className={classes.face} data-tone={tone}>
<div className={classes.top}>
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
{card.archivedAt && (
<span className={classes.archive}>{t("bank.archived")}</span>
)}
</div>
<div aria-hidden="true" className={classes.hardware}>
<span className={classes.chip} />
<svg
aria-hidden="true"
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>
<ContactlessIcon height="30" viewBox="0 0 24 30" />
</div>
<div
className={classes.number}
>
<div className={classes.number}>
{`•••• •••• •••• ${card.lastFour || "••••"}`}
</div>
<div className={classes.bottom}>
<div className={classes.details}>
<div className={classes.name}>{card.name}</div>
<div className={classes.balance}>{formatMoney(card.balance)}</div>
</div>
{card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && (
<span aria-hidden="true" className={classes.circles} />
)}
{t(`bank.paymentSystems.${card.paymentSystem}`)}
</span>
)}
@@ -66,6 +62,7 @@ function bankTone(bank: string) {
if (/сбер|sber/.test(bank)) return "green";
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
if (/альфа|alfa/.test(bank)) return "red";
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
}
@@ -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() {
Review

возможно лучше использовать внутри дополнительные хуки, в которых будет инкапсулировано поведение по смыслу, например все on* действия вынести в отдельный хук, который в хуке useBankCardFieldsVm будет просто отдавать наружу детали и т.д

возможно так будет понятнее логика + сделать в виде guardrails

возможно лучше использовать внутри дополнительные хуки, в которых будет инкапсулировано поведение по смыслу, например все on* действия вынести в отдельный хук, который в хуке useBankCardFieldsVm будет просто отдавать наружу детали и т.д возможно так будет понятнее логика + сделать в виде guardrails
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 type { BankCardValues } from "../../model/form-validation";
import type { BankCardValues } from "../../model/form.schema";
import { BankCardFields } from "./BankCardForm.view";
@@ -20,6 +20,7 @@ const meta = {
openingDate: "2026-08-01",
},
});
return (
<FormProvider {...methods}>
<Story />
@@ -38,8 +39,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/BankCards/CardFields",
} satisfies Meta<typeof BankCardFields>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
args: { archived: true },
@@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries";
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,
balance: { currency: "RUB", value: "50" },
bankName: "Банк",
@@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
@@ -41,8 +44,10 @@ function setup(value: BankCard) {
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("BankCardForm", () => {
it("waits for the exit transition before notifying the parent", async () => {
const close = setup(card);
@@ -64,20 +69,23 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json(card);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
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,
lastFour: null,
name: "Основная",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */));
},
/* eslint-enable unicorn/no-null */
);
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
@@ -92,6 +100,7 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
@@ -132,6 +141,7 @@ describe("BankCardForm", () => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
version = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -146,9 +156,11 @@ describe("BankCardForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt);
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 { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import {
bankCardSchema,
type BankCardValues,
bankMoneyValue,
bankToday,
} from "../../model/form-validation";
import { type BankCard } from "../../api/queries";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
const BankCardForm: FC<BankCardFormProps> = (props) => {
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 {
dialogTitle,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
t,
} = useBankCardFormVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t(props.card ? "bank.editCard" : "bank.addCard")}
title={dialogTitle}
>
<FormProvider {...methods}>
<form
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_);
}
})}
>
<form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
{props.card && (
<Button
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
variant="default"
>
<Button onClick={onReloadRecord} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
</>
))}
)}
{latest && (
<>
<Text>
@@ -123,23 +63,19 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
})}
</Text>
<Button
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
variant="default"
>
<Button onClick={onAdoptVersion} variant="default">
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button loading={isPending} type="submit">
{t("bank.save")}
</Button>
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -2,24 +2,28 @@ import type { FC } from "react";
import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "../../model/form-validation";
import { bankToday } from "../../model/form-validation";
import { useBankCardFieldsVm } from "./BankCardFields.vm";
type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const {
control,
formState: { errors },
dateParser,
errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register,
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
t,
} = useBankCardFieldsVm();
return (
<Stack>
<TextInput
@@ -28,37 +32,36 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("name")}
error={errors.name?.message}
/>
<TextInput
label={t("bank.bankName")}
{...register("bankName")}
error={errors.bankName?.message}
/>
<TextInput
inputMode="numeric"
label={t("bank.lastFour")}
{...register("lastFour")}
error={errors.lastFour?.message}
/>
<Controller
control={control}
name="paymentSystem"
render={({ field }) => (
<Select
{...field}
clearable
comboboxProps={{ withinPortal: false }}
data={["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}))}
error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={value => field.onChange(value ?? "")}
placeholder={t("bank.paymentSystemUnknown")}
value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
/>
)}
<Select
{...paymentSystemField}
clearable
comboboxProps={{ withinPortal: false }}
data={paymentSystems}
error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={onPaymentSystemChange}
placeholder={t("bank.paymentSystemUnknown")}
value={
paymentSystemField.value
// eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
|| null
}
/>
<TextInput
inputMode="decimal"
label={t("bank.openingBalance")}
@@ -67,43 +70,31 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("openingBalance")}
error={errors.openingBalance?.message}
/>
<Controller
control={control}
name="openingDate"
render={({ field }) => (
<DateInput
{...field}
dateParser={value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.openingDate?.message}
fixOnBlur={false}
label={t("bank.openingDate")}
maxDate={bankToday()}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
}}
onChange={value => field.onChange(value ?? "")}
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"
/>
)}
<DateInput
{...openingDateField}
dateParser={dateParser}
error={errors.openingDate?.message}
fixOnBlur={false}
label={t("bank.openingDate")}
maxDate={bankToday()}
onBlur={onOpeningDateBlur}
onChange={onOpeningDateChange}
onInput={onOpeningDateInput}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
readOnly={props.archived}
required
value={openingDateValue}
valueFormat="DD.MM.YYYY"
/>
<Text c="dimmed" size="sm">
{t("bank.openingHint")}
</Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
</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;
retry?: () => void;
};
const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation();
const code = apiErrorCode(props.error);
return (
<Alert color="orange" role="alert">
<Group>
@@ -26,6 +28,7 @@ const BankFeedback: FC<BankFeedbackProps> = (props) => {
),
})}
</Text>
{props.retry && (
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
@@ -1,9 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
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 = {
args: { categoryName: "Продукты", kind: "expense" },
@@ -11,8 +19,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/BankCards/OperationIcons",
} satisfies Meta<typeof BankOperationIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Categories: Story = {
render: () => (
<Stack maw={640}>
@@ -55,6 +66,7 @@ export const Categories: Story = {
valueLabel="Расход"
/>
))}
<EntryCard
color="green"
icon={<BankOperationIcon kind="topup" />}
@@ -1,12 +1,21 @@
import { render } from "@testing-library/react";
import { OperationIcon } from "@wealthledger/icons";
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", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Жильё" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path();
@@ -15,16 +24,25 @@ it("matches normalized category names, falls back after renaming and distinguish
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path();
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" />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
expect(path()).toBeTruthy();
paths.push(path());
}
expect(new Set(paths).size).toBe(5);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
@@ -34,8 +52,10 @@ it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Другое" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
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 { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import { useBankPeriodPickerVm } from "./BankPeriodPicker.vm";
type BankPeriodPickerProps = {
onChange: (value: [null | string, null | string]) => void;
period: { from: string; to: string };
};
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
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, t, value } = useBankPeriodPickerVm(props);
return (
<DatePickerInput
allowSingleDateInRange
label={t("bank.period")}
onChange={(next) => {
setDraft({ scope, value: next });
onChange(next);
}}
onChange={onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: 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";
const money = (value: string) => ({ currency: "RUB", value });
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
data: {
cardId: null,
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
net: money("2500"),
refunds: money("1000"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
net: money("2200"),
refunds: money("0"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
net: money("1800"),
refunds: money("0"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
net: money("1000"),
refunds: money("0"),
},
],
expenses: money("8500"),
month: "2026-09",
net: money("7500"),
refunds: money("1000"),
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const meta
= {
args: {
data: {
cardId: null,
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
net: money("2500"),
refunds: money("1000"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
net: money("2200"),
refunds: money("0"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
net: money("1800"),
refunds: money("0"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
net: money("1000"),
refunds: money("0"),
},
],
expenses: money("8500"),
month: "2026-09",
net: money("7500"),
refunds: money("1000"),
},
error: false,
fetching: false,
loading: false,
retry: () => {},
},
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
parameters: {
docs: {
description: {
component:
"Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Один итог с учётом возвратов и компактные строки категорий. Отрицательные суммы сохраняют знак.",
component: BankSummaryView,
parameters: {
docs: {
description: {
component:
"Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Один итог с учётом возвратов и компактные строки категорий. Отрицательные суммы сохраняют знак.",
},
},
},
},
tags: ["autodocs"],
title: "Product/BankCards/Summary",
}
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankSummaryView>;
tags: ["autodocs"],
title: "Product/BankCards/Summary",
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankSummaryView>;
export default meta;
type Story = StoryObj<typeof meta>;
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: {
data: {
cardId: null,
@@ -73,7 +79,7 @@ export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture
{
categoryId: "shopping",
categoryName:
"Покупки с длинным пользовательским названием категории",
"Покупки с длинным пользовательским названием категории",
expenses: money("0"),
net: money("-600"),
refunds: money("600"),
@@ -86,10 +92,11 @@ export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture
},
},
name: "Возврат покупки прошлого месяца",
}
/* eslint-enable unicorn/no-null */);
export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Empty: Story
= {
args: {
data: {
cardId: null,
@@ -101,8 +108,8 @@ export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
},
},
name: "Известные нули",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const Loading: Story = {
args: { data: undefined, loading: true },
name: "Начальная загрузка",
@@ -115,8 +122,9 @@ export const CachedError: Story = {
args: { error: true },
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: {
data: {
cardId: null,
@@ -136,5 +144,5 @@ export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture
},
},
name: "Точный итог длиннее предела отдельного ввода",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
@@ -1,6 +1,7 @@
import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -8,7 +9,7 @@ import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2;
@@ -21,77 +22,93 @@ type BankSummaryViewProps = {
loading: boolean;
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 { 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 (
<Stack aria-busy={props.loading || props.fetching} gap="md">
<div className={classes.header}>
<Title order={2}>
{t(props.all ? "bank.allSummary" : "bank.summary")}
</Title>
{props.data && (
<Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</Text>
)}
</div>
{renderCategories()}
<BankSummaryViewCategories {...props} />
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{props.data && props.fetching && (
<Text role="status">{t("bank.refreshing")}</Text>
)}
@@ -23,18 +23,23 @@ describe("CategoryManager", () => {
clear: () => {},
refresh: () => Promise.resolve(false),
});
const categories = [
{ id: "a", isDefault: false, name: "Первая", version: 1 },
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
];
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 }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map(category =>
category.id === "a" && reads > 1
@@ -51,6 +56,7 @@ describe("CategoryManager", () => {
"*/api/v1/bank-cards/categories/:id",
async ({ params, request }) => {
patches.push({ body: await request.json(), id: params.id });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -67,7 +73,11 @@ describe("CategoryManager", () => {
</MantineProvider>
</QueryClientProvider>,
);
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
const renameButtons = await screen.findAllByRole("button", {
name: "Переименовать",
});
fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
@@ -122,6 +132,7 @@ describe("CategoryManager", () => {
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -163,12 +174,14 @@ describe("CategoryManager", () => {
clear: () => {},
refresh: () => Promise.resolve(false),
});
let category = {
id: "default",
isDefault: true,
name: "Другое",
version: 1,
};
let creates = 0;
let body: unknown;
server.use(
@@ -177,6 +190,7 @@ describe("CategoryManager", () => {
),
http.post("*/api/v1/bank-cards/categories", () => {
creates++;
return HttpResponse.json({});
}),
http.patch(
@@ -184,6 +198,7 @@ describe("CategoryManager", () => {
async ({ request }) => {
body = await request.json();
category = { ...category, name: "Остальное", version: 2 };
return HttpResponse.json(category);
},
),
@@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Badge,
Button,
@@ -8,218 +7,148 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
useForm,
useFormContext,
} from "react-hook-form";
import { OperationIcon } from "@wealthledger/icons";
import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import { useBankFormFocus } from "../useBankFormFocus";
const MAX_CATEGORY_NAME_LENGTH = 80;
import { useCategoryManagerVm } from "./CategoryManager.vm";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => {
const { t } = useTranslation();
const {
control,
formState: { errors },
} = useFormContext<CategoryValues>();
const { field: nameField } = useController({ control, name: "name" });
return (
<Controller
control={control}
name="name"
render={({ field }) => (
<TextInput
label={t("bank.categoryName")}
required
{...field}
error={errors.name?.message}
/>
)}
<TextInput
label={t("bank.categoryName")}
required
{...nameField}
error={errors.name?.message}
/>
);
};
const CategoryManager: FC<CategoryManagerProps> = (props) => {
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 = 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.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;
const {
categories,
categoryRows,
editing,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onDismiss,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
retryCategories,
t,
} = useCategoryManagerVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t("bank.categories")}
>
<FormProvider {...methods}>
<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);
}}
>
<form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
{categories.data?.map(category => (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<BankOperationIcon
categoryName={category.name}
kind="expense"
/>
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
{categoryRows?.map((category) => {
return (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<OperationIcon
{...bankOperationAppearance("expense", category.name)}
/>
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
</Group>
<Button onClick={category.onRename} variant="subtle">
{t("bank.rename")}
</Button>
</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 && (
<Text role="status">{t("bank.loading")}</Text>
)}
{categories.isError && (
<BankFeedback
cached={Boolean(categories.data)}
error={categories.error}
loadingError
retry={() => void categories.refetch()}
retry={retryCategories}
/>
)}
<CategoryFields />
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
{editing && (
<Button
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"
>
<Button onClick={onReloadRecord} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
</>
))}
)}
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button
onClick={() => {
setEditing(latest);
setLatest(undefined);
setError(undefined);
}}
>
<Button onClick={onAdoptVersion}>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button loading={isPending} type="submit">
{t(editing ? "bank.save" : "bank.addCategory")}
</Button>
{editing && (
<Button
onClick={() => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
variant="default"
>
<Button onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
)}
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onDismiss} variant="default">
{t("bank.close")}
</Button>
</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,
};
}
function useBond(identifier: string) {
const isUid
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
@@ -28,6 +29,7 @@ function useBond(identifier: string) {
...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
@@ -35,6 +37,7 @@ function useBond(identifier: string) {
enabled: Boolean(identifier) && !isUid,
staleTime: BOND_STALE_TIME_MS,
});
return isUid ? byUid : byTicker;
}
@@ -47,6 +50,7 @@ function useBondCandles(uid: string, period: BondPeriod) {
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondCoupons(uid: string) {
return useResourceQuery({
meta: { accessGroup: "brokerage" },
@@ -57,6 +61,7 @@ function useBondCoupons(uid: string) {
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondMetrics(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
@@ -66,6 +71,7 @@ function useBondMetrics(uid: string) {
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondPosition(uid: string) {
return useResourceQuery({
meta: { accessGroup: "brokerage" },
@@ -76,6 +82,7 @@ function useBondPosition(uid: string) {
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondQuote(uid: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
@@ -85,6 +92,7 @@ function useBondQuote(uid: string) {
staleTime: BOND_STALE_TIME_MS,
});
}
function useBondSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
@@ -95,7 +103,8 @@ function useBondSearch(query: string) {
});
}
export { type BondPeriod,
export {
type BondPeriod,
MIN_BOND_SEARCH_LENGTH,
useBond,
useBondCandles,
+2 -1
View File
@@ -1,4 +1,5 @@
export { type BondPeriod,
export {
type BondPeriod,
MIN_BOND_SEARCH_LENGTH,
useBond,
useBondCandles,

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