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

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -9,25 +9,30 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
vi.spyOn(globalThis, "matchMedia").mockReturnValue({ vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true, matches: true,
} as MediaQueryList); } as MediaQueryList);
const { container } = render( const { container } = render(
<button type="button"> <button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} /> <svg aria-hidden="true" ref={bindIconAnimation} />
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button"); const target = screen.getByRole("button");
let finishFirst!: () => void; let finishFirst!: () => void;
let finishLast!: () => void; let finishLast!: () => void;
const getAnimations = vi.fn(() => { const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>(); const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>(); const last = Promise.withResolvers<void>();
finishFirst = first.resolve; finishFirst = first.resolve;
finishLast = last.resolve; finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }]; return [{ finished: first.promise }, { finished: last.promise }];
}); });
Object.defineProperty(icon, "getAnimations", { value: getAnimations }); Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target); fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target); fireEvent.mouseLeave(target);
@@ -53,6 +58,7 @@ it("keeps playing after keyboard blur and releases cancelled animations", async
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
@@ -13,6 +13,7 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
delete icon.dataset.iconAnimating; delete icon.dataset.iconAnimating;
} }
}; };
const start = () => { const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return; if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = ""; icon.dataset.iconAnimating = "";
@@ -27,21 +28,25 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
release(); release();
}); });
}; };
const enter = () => { const enter = () => {
if (!matchMedia("(hover: hover)").matches) return; if (!matchMedia("(hover: hover)").matches) return;
isHovered = true; isHovered = true;
start(); start();
}; };
const leave = () => { const leave = () => {
isHovered = false; isHovered = false;
release(); release();
}; };
const focus = () => { const focus = () => {
isFocused isFocused
= target.matches(":focus-visible") = target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible"); || !!target.querySelector(":focus-visible");
if (isFocused) start(); if (isFocused) start();
}; };
const blur = (event: FocusEvent) => { const blur = (event: FocusEvent) => {
if ( if (
event.relatedTarget instanceof Node event.relatedTarget instanceof Node
@@ -51,10 +56,12 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
isFocused = false; isFocused = false;
release(); release();
}; };
target.addEventListener("mouseenter", enter); target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave); target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus); target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur); target.addEventListener("focusout", blur);
return () => { return () => {
isDisposed = true; isDisposed = true;
target.removeEventListener("mouseenter", enter); target.removeEventListener("mouseenter", enter);
+13
View File
@@ -1,4 +1,17 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
export { AddIcon } from "./svg/AddIcon"; export { AddIcon } from "./svg/AddIcon";
export { BondsIcon } from "./svg/BondsIcon";
export { CardsIcon } from "./svg/CardsIcon";
export { CategoriesIcon } from "./svg/CategoriesIcon";
export { ContactlessIcon } from "./svg/ContactlessIcon";
export { DeleteIcon } from "./svg/DeleteIcon"; export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon"; export { EditIcon } from "./svg/EditIcon";
export { PortfolioIcon } from "./svg/PortfolioIcon";
export { RestoreIcon } from "./svg/RestoreIcon";
export { SettingsIcon } from "./svg/SettingsIcon";
export { StocksIcon } from "./svg/StocksIcon";
export { WalletIcon } from "./svg/WalletIcon";
export type { IconProps } from "./types"; export type { IconProps } from "./types";
+179
View File
@@ -0,0 +1,179 @@
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const AddIcon: FC<IconProps> = (props) => { const AddIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const BondsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</svg>
);
};
export { BondsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const CardsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</svg>
);
};
export { CardsIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const CategoriesIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
);
};
export { CategoriesIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const ContactlessIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
};
export { ContactlessIcon };
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const DeleteIcon: FC<IconProps> = (props) => { const DeleteIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => { const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
+43
View File
@@ -0,0 +1,43 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const PortfolioIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</svg>
);
};
export { PortfolioIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const RestoreIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
);
};
export { RestoreIcon };
+35
View File
@@ -0,0 +1,35 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const SettingsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</svg>
);
};
export { SettingsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const StocksIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</svg>
);
};
export { StocksIcon };
+29
View File
@@ -0,0 +1,29 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const WalletIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
);
};
export { WalletIcon };
+11 -3
View File
@@ -1,7 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": [
"ES2023",
"DOM"
],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true, "strict": true,
@@ -11,7 +14,12 @@
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"jsx": "react-jsx" "jsx": "react-jsx",
"types": [
"vite/client"
]
}, },
"include": ["src/index.ts"] "include": [
"src/index.ts"
]
} }
@@ -35,5 +35,7 @@ const meta = {
} satisfies Meta<typeof EntryCard>; } satisfies Meta<typeof EntryCard>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
@@ -31,11 +31,13 @@ describe("EntryCard", () => {
value: "—", value: "—",
valueLabel: "Неизвестно", valueLabel: "Неизвестно",
}; };
const { rerender } = render( const { rerender } = render(
<MantineProvider> <MantineProvider>
<EntryCard {...props} /> <EntryCard {...props} />
</MantineProvider>, </MantineProvider>,
); );
// Decorative image has no accessible name; trigger its actual failure event. // Decorative image has no accessible name; trigger its actual failure event.
const image = screen.queryByAltText(""); const image = screen.queryByAltText("");
assert(image); assert(image);
+21 -17
View File
@@ -13,24 +13,25 @@ type EntryCardProps = {
initial: string; initial: string;
metadata: string; metadata: string;
title: string; title: string;
tone: "negative" | "neutral" | "positive"; tone: "negative"
| "neutral"
| "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
return ( const surfaceStyle = props.color
<Paper ? {
className={classes.row} background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
style={
(props.color)
? ({
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
})
: undefined
} }
withBorder : undefined;
>
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return (
<Paper className={classes.row} style={surfaceStyle} withBorder>
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
@@ -40,26 +41,29 @@ const EntryCard: FC<EntryCardProps> = (props) => {
> >
{props.icon ?? props.initial} {props.icon ?? props.initial}
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={1}>
{props.title} {props.title}
</Text> </Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{props.metadata} {props.metadata}
</Text> </Text>
{props.details} {props.details}
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}> <Text className={classes[props.tone]} fw={700}>
{props.value} {props.value}
</Text> </Text>
<div className={(props.actions) ? (classes.valueFooter) : undefined}>
<Text <div className={valueContainerClass}>
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`} <Text className={valueLabelClass} size="sm">
size="sm"
>
{props.valueLabel} {props.valueLabel}
</Text> </Text>
{Boolean(props.actions) && ( {Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div> <div className={classes.actions}>{props.actions}</div>
)} )}
@@ -23,7 +23,9 @@ const meta = {
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...arguments_}> <LoadingSkeleton {...arguments_}>
<Skeleton h={24} w="60%" /> <Skeleton h={24} w="60%" />
<Skeleton h={72} /> <Skeleton h={72} />
<Skeleton h={16} w="80%" /> <Skeleton h={16} w="80%" />
</LoadingSkeleton> </LoadingSkeleton>
</Paper> </Paper>
@@ -33,5 +35,7 @@ const meta = {
} satisfies Meta<typeof LoadingSkeleton>; } satisfies Meta<typeof LoadingSkeleton>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" }; export const Initial: Story = { name: "Загрузка области" };
@@ -41,16 +41,17 @@ const meta = {
}, },
render: (arguments_) => { render: (arguments_) => {
const { onAction, withAction, ...stateProps } = arguments_; const { onAction, withAction, ...stateProps } = arguments_;
return ( return (
<StateView <StateView
{...stateProps} {...stateProps}
action={ action={
(withAction) withAction
? (( ? (
<Button onClick={onAction} variant="default"> <Button onClick={onAction} variant="default">
{arguments_.error ? "Повторить локально" : "Выполнить пример"} {arguments_.error ? "Повторить локально" : "Выполнить пример"}
</Button> </Button>
)) )
: undefined : undefined
} }
/> />
@@ -63,6 +64,7 @@ const meta = {
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" }; export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = { export const EmptyWithAction: Story = {
args: { withAction: true }, args: { withAction: true },
+2
View File
@@ -17,7 +17,9 @@ const StateView: FC<StateViewProps> = (props) => {
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder> <Paper p="xl" radius="md" role={role} shadow="xs" withBorder>
<Stack align="flex-start" gap="sm"> <Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text> <Text c="dimmed">{text}</Text>
{action} {action}
</Stack> </Stack>
</Paper> </Paper>
+3 -1
View File
@@ -1,4 +1,3 @@
import "./styles.css";
import { import {
colorsTuple, colorsTuple,
createTheme, createTheme,
@@ -11,8 +10,11 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json"; import designTokens from "@wealthledger/tokens/tokens.json";
import "./styles.css";
const { breakpoints, color, radius, shadows, spacing, typography } const { breakpoints, color, radius, shadows, spacing, typography }
= designTokens; = designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) => const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries( Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, convert(value)]), Object.entries(values).map(([name, value]) => [name, convert(value)]),
+10 -1
View File
@@ -14,14 +14,20 @@ const { spawnSync } = require("node:child_process");
test("immutable publication resumes exact digest checks after post-push failure", () => { test("immutable publication resumes exact digest checks after post-push failure", () => {
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-")); const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync( const workflow = readFileSync(
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"), nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
"utf8", "utf8",
); );
const block = workflow const block = workflow
.split(" - name: Publish immutable release tag\n run: |\n", 2)[1] .split(
" - name: Publish immutable release tag\n run: |\n",
2,
)[1]
.split(" - name:", 1)[0] .split(" - name:", 1)[0]
.replaceAll(/^ {10}/gm, ""); .replaceAll(/^ {10}/gm, "");
const double = String.raw`#!${process.execPath} const double = String.raw`#!${process.execPath}
const fs = require('node:fs'); const fs = require('node:fs');
const args = process.argv.slice(2).join(' '); const args = process.argv.slice(2).join(' ');
@@ -39,6 +45,7 @@ if (args.startsWith('manifest inspect')) {
console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit'); console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1); } else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
`; `;
try { try {
for (const name of ["docker", "npm"]) { for (const name of ["docker", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
@@ -46,6 +53,7 @@ if (args.startsWith('manifest inspect')) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE); chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
} }
const run = (extra = {}) => const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], { spawnSync("/bin/sh", ["-ec", block], {
cwd: directory, cwd: directory,
@@ -59,6 +67,7 @@ if (args.startsWith('manifest inspect')) {
...extra, ...extra,
}, },
}); });
assert.notEqual(run().status, 0); assert.notEqual(run().status, 0);
const retry = run(); const retry = run();
assert.equal(retry.status, 0, retry.stdout + retry.stderr); assert.equal(retry.status, 0, retry.stdout + retry.stderr);
+2
View File
@@ -6,6 +6,7 @@ import nodePath from "node:path";
const temporaryDirectory = await mkdtemp( const temporaryDirectory = await mkdtemp(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"), nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
); );
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts"); const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts"); const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js"); const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
@@ -24,6 +25,7 @@ try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
readFile(generatedPath, "utf8"), readFile(generatedPath, "utf8"),
]); ]);
if (committed !== current) { if (committed !== current) {
console.error( console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.", "Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
+5 -2
View File
@@ -2,14 +2,16 @@ const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process"); const { execFileSync } = require("node:child_process");
const { version } = require("../package.json"); const { version } = require("../package.json");
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim(); const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
assert.equal( assert.equal(
git("status", "--porcelain"), git("status", "--porcelain"),
"", "",
"Commit all changes before preparing a release", "Commit all changes before preparing a release",
); );
const tag_ = git( const tag_ = git(
"tag", "tag",
"--merged", "--merged",
@@ -20,6 +22,7 @@ const tag_ = git(
) )
.split("\n") .split("\n")
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag)); .find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal( assert.equal(
version, version,
tag_?.slice(1) ?? "0.0.0", tag_?.slice(1) ?? "0.0.0",
+139 -10
View File
@@ -16,11 +16,13 @@ import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library"; import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn"; import unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { globSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => { test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint(); const eslint = new ESLint();
for (const [filePath, code, rule] of [ for (const [filePath, code, rule] of [
[ [
"src/shared/api/problem-details.ts", "src/shared/api/problem-details.ts",
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
rule, rule,
); );
} }
for (const file of [ for (const file of [
"src/shared/api/generated/schema.d.ts", "src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js", "packages/ui/dist/index.js",
@@ -117,19 +120,22 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"coverage/index.js", "coverage/index.js",
]) ])
assert.equal(await eslint.isPathIgnored(file), true, file); assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile( const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx", "src/shared/ui/Button.stories.tsx",
); );
assert.equal(story.rules["import-x/no-default-export"][0], 0); assert.equal(story.rules["import-x/no-default-export"][0], 0);
assert.ok(story.rules["storybook/default-exports"][0] > 0); assert.ok(story.rules["storybook/default-exports"][0] > 0);
assert.equal(story.rules["react-refresh/only-export-components"], undefined); assert.equal(story.rules["react-refresh/only-export-components"], undefined);
}); });
test("all recommended plugin rules remain enabled in their intended scopes", async () => { test("recommended plugin rules retain the explicit null-contract exception", async () => {
const eslint = new ESLint(); const eslint = new ESLint();
const source = "src/shared/api/problem-details.ts"; const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx"; const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx"; const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [ for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source], [tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source], [[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
@@ -147,15 +153,34 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
[vitest.configs.recommended, unit], [vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit], [testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit], [jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]), ...storybook.configs["flat/recommended"]
.filter(config => config.rules)
.map(config => [
config,
config.name.endsWith("main-rules")
? ".storybook/main.ts"
: "src/shared/ui/Button.stories.tsx",
]),
]) { ]) {
const configs = [preset].flat(); const configs = [preset].flat();
const expected = Object.assign({}, ...configs.map(config => config.rules));
const expected = Object.assign(
{},
...configs.map(config => config.rules),
);
const config = await eslint.calculateConfigForFile(filePath); const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules)); const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) { for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting; const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue; if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
if (rule === "unicorn/no-null") {
assert.equal(actual.get(rule)?.[0], 0);
continue;
}
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`); assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
} }
} }
@@ -164,10 +189,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => { test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText( const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};", "//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath }, { filePath },
); );
assert.equal(fixed.fatalErrorCount, 0); assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0); assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output); assert.ok(fixed.output);
@@ -178,10 +205,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async (
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => { test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText( const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };", "const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" }, { filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
); );
assert.equal(fixed.errorCount, 0); assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}")); assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
}); });
@@ -190,11 +219,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => { test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
const eslint = new ESLint(); const eslint = new ESLint();
const filePath = "src/shared/api/problem-details.ts"; const filePath = "src/shared/api/problem-details.ts";
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
for (const code of [
"export function check(code: string) { return code === \"one-off-code\"; }",
"export const result = `MOEX_UNAVAILABLE`;",
"export const result = { code: \"one-off-code\" };",
"export const result = { \"errorCode\": \"one-off-code\" };",
"export const result = { code: `dynamic-${Date.now()}` };",
"export const result = \"MOEX_UNAVAILABLE\";",
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code); assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code); assert.ok(
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
code,
);
} }
for (const code of [ for (const code of [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };", "enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"export const select = { code: true, errorCode: true };", "export const select = { code: true, errorCode: true };",
@@ -202,10 +245,96 @@ test("machine codes require enum members without banning ordinary text or Prisma
]) { ]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code); assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code); assert.ok(
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
code,
);
} }
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax")); const [fixture] = await eslint.lintText(
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath }); "export const result = { code: \"MOEX_UNAVAILABLE\" };",
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison")); { filePath: "src/shared/api/client.test.ts" },
);
assert.ok(
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
);
const [comparison] = await eslint.lintText(
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
{ filePath },
);
assert.ok(
comparison.messages.some(
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
),
);
});
test("readability enforces syntax without banning VM and schema patterns", async () => {
const eslint = new ESLint();
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
const model = "src/entities/income/api/useIncome.ts";
const invalid = [
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
];
for (const [filePath, code, rule] of invalid) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
}
for (const [filePath, code] of [
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
}
assert.deepEqual(globSync("src/**/*.svg"), []);
});
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
const output = fixed.output;
assert.ok(output);
const [again] = await eslint.lintText(output, { filePath });
assert.equal(again.output, undefined);
assert.equal(again.errorCount, 0);
});
test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const code = "import type { FC } from 'react'; type Props = { active: boolean }; const Example: FC<Props> = (props) => props.active ? <span /> : <div />;\nexport { Example };";
const [fixed] = await eslint.lintText(code, { filePath });
assert.equal(fixed.errorCount, 0);
assert.match(fixed.output ?? code, /\? <span \/> : <div \/>/);
const [nullable] = await eslint.lintText(
"export const value: string | null = null;",
{ filePath: "src/shared/api/problem-details.ts" },
);
assert.equal(nullable.errorCount, 0);
}); });
+14 -5
View File
@@ -20,23 +20,32 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
const bin = nodePath.join(cwd, "bin"); const bin = nodePath.join(cwd, "bin");
const marker = nodePath.join(cwd, "downstream"); const marker = nodePath.join(cwd, "downstream");
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`; const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" }); const git = (...arguments_) =>
execFileSync("git", arguments_, { cwd, stdio: "pipe" }); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) => const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], { spawnSync("/bin/sh", ["-e", hook], {
cwd, cwd,
encoding: "utf8", encoding: "utf8",
env: { ...process.env, PATH: path }, env: { ...process.env, PATH: path },
}); });
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin); mkdirSync(bin);
for (const command of ["npx", "npm"]) { for (const command of ["npx", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { writeFileSync(
mode: 0o755, nodePath.join(bin, command),
}); "#!/bin/sh\necho ran >> downstream\n",
{
mode: 0o755,
},
);
} }
git("init", "--quiet"); git("init", "--quiet");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
+63 -42
View File
@@ -28,8 +28,9 @@ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
]; ];
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ const position: Portfolio["positions"][number]
= {
absoluteReturn: null, absoluteReturn: null,
averagePurchasePrice: null, averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" }, currentValue: { currency: "RUB", value: "600" },
@@ -40,8 +41,8 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
quantity: "2", quantity: "2",
ticker: "SBER", ticker: "SBER",
type: "STOCK", type: "STOCK",
} };
/* eslint-enable unicorn/no-null */);
const portfolio: Portfolio = { const portfolio: Portfolio = {
cashBalances: [], cashBalances: [],
isStale: false, isStale: false,
@@ -50,8 +51,9 @@ const portfolio: Portfolio = {
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }], totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ const receipt: BrokerageOperation
= {
accruedInterest: null, accruedInterest: null,
category: "income", category: "income",
commission: null, commission: null,
@@ -67,11 +69,12 @@ const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixtur
quantity: null, quantity: null,
ticker: "BOND", ticker: "BOND",
type: "OPERATION_TYPE_COUPON", type: "OPERATION_TYPE_COUPON",
} };
/* eslint-enable unicorn/no-null */);
const items = accounts.map(account => ({ ...receipt, account })); const items = accounts.map(account => ({ ...receipt, account }));
const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ const analytics: IncomeAnalytics
= {
accountId: "all", accountId: "all",
bucketGranularity: "day", bucketGranularity: "day",
buckets: [], buckets: [],
@@ -105,17 +108,20 @@ const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture
}, },
], ],
yearComparison: null, yearComparison: null,
} };
/* eslint-enable unicorn/no-null */);
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
const location = useLocation(); const location = useLocation();
return ( return (
<output aria-label="Текущий путь"> <output aria-label="Текущий путь">
{location.pathname} {location.pathname}
{location.search} {location.search}
</output> </output>
); );
}; };
function renderPage(path: string) { function renderPage(path: string) {
const client = createTestQueryClient(); const client = createTestQueryClient();
render( render(
@@ -124,12 +130,14 @@ function renderPage(path: string) {
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<AuthProvider> <AuthProvider>
<AppRoutes /> <AppRoutes />
<CurrentLocation /> <CurrentLocation />
</AuthProvider> </AuthProvider>
</MemoryRouter> </MemoryRouter>
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
return client; return client;
} }
@@ -144,14 +152,16 @@ beforeEach(() => {
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ accounts.map(
{
...account, account =>
openedOn: null, ({
portfolioSummary: null, ...account,
type: "BROKERAGE", openedOn: null,
} portfolioSummary: null,
/* eslint-enable unicorn/no-null */))), type: "BROKERAGE",
}),
),
), ),
), ),
), ),
@@ -162,15 +172,16 @@ beforeEach(() => {
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) => ({ params }) =>
HttpResponse.json( HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ {
accountId: params.accountId, accountId: params.accountId,
hasNext: false, hasNext: false,
items, items,
nextCursor: null, nextCursor: null,
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
} },
/* eslint-enable unicorn/no-null */)), ),
), ),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
@@ -181,21 +192,23 @@ beforeEach(() => {
resource({ resource({
accountId: "all", accountId: "all",
calculatedAt: receipt.occurredAt, calculatedAt: receipt.occurredAt,
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ events: accounts.map(
{
account, account =>
date: "2026-09-15", ({
dateKind: "event", account,
displayName: "Облигация", date: "2026-09-15",
figi: null, dateKind: "event",
instrumentUid: null, displayName: "Облигация",
isin: null, figi: null,
logoName: null, instrumentUid: null,
projectedAmount: { currency: "RUB", value: "2" }, isin: null,
ticker: "BOND", logoName: null,
type: "coupon", projectedAmount: { currency: "RUB", value: "2" },
} ticker: "BOND",
/* eslint-enable unicorn/no-null */))), type: "coupon",
}),
),
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
}), }),
), ),
@@ -235,7 +248,11 @@ describe("All accounts routes", () => {
); );
for (const suffix of ["operations", "income", "events"]) for (const suffix of ["operations", "income", "events"])
expect( expect(
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`), screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -247,8 +264,10 @@ describe("All accounts routes", () => {
({ params, request }) => { ({ params, request }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url); requests.push(url);
return HttpResponse.json( return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ {
accountId: params.accountId, accountId: params.accountId,
hasNext: !url.searchParams.has("cursor"), hasNext: !url.searchParams.has("cursor"),
@@ -262,8 +281,8 @@ describe("All accounts routes", () => {
], ],
nextCursor: url.searchParams.has("cursor") ? null : "next", nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
} },
/* eslint-enable unicorn/no-null */)), ),
); );
}, },
), ),
@@ -281,9 +300,11 @@ describe("All accounts routes", () => {
requests.some(url => url.pathname.includes("/b/operations")), requests.some(url => url.pathname.includes("/b/operations")),
).toBe(true), ).toBe(true),
); );
const selected = requests.find(url => const selected = requests.find(url =>
url.pathname.includes("/b/operations"), url.pathname.includes("/b/operations"),
); );
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z"); expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z"); expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
expect(selected?.searchParams.get("categories")).toBe("income"); expect(selected?.searchParams.get("categories")).toBe("income");
+5 -3
View File
@@ -62,6 +62,7 @@ describe("AppRoutes", () => {
server.use( server.use(
http.post("*/api/v1/auth/login", async ({ request }) => { http.post("*/api/v1/auth/login", async ({ request }) => {
credentials = await request.json(); credentials = await request.json();
return HttpResponse.json({ return HttpResponse.json({
accessToken: "login-token", accessToken: "login-token",
user: { email: "user@example.com", id: "user-1" }, user: { email: "user@example.com", id: "user-1" },
@@ -103,7 +104,8 @@ describe("AppRoutes", () => {
), ),
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
http.get("*/api/v1/bank-cards/summary", () => http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{ {
cardId: null, cardId: null,
categories: [], categories: [],
@@ -111,8 +113,8 @@ describe("AppRoutes", () => {
month: "2026-08", month: "2026-08",
net: { currency: "RUB", value: "0" }, net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" }, refunds: { currency: "RUB", value: "0" },
} },
/* eslint-enable unicorn/no-null */)), ),
), ),
); );
renderRoutes("/cards?month=2026-08"); renderRoutes("/cards?month=2026-08");
+50
View File
@@ -8,72 +8,104 @@ import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => { const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card"); const { CardPage } = await import("@/pages/card");
return { default: CardPage }; return { default: CardPage };
}); });
const CardsPage = lazy(async () => { const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards"); const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage }; return { default: CardsPage };
}); });
const BondPage = lazy(async () => { const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond"); const { BondPage } = await import("@/pages/bond");
return { default: BondPage }; return { default: BondPage };
}); });
const BondsPage = lazy(async () => { const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds"); const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage }; return { default: BondsPage };
}); });
const StockPage = lazy(async () => { const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock"); const { StockPage } = await import("@/pages/stock");
return { default: StockPage }; return { default: StockPage };
}); });
const StocksPage = lazy(async () => { const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks"); const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage }; return { default: StocksPage };
}); });
const AccountPage = lazy(async () => { const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account"); const { AccountPage } = await import("@/pages/account");
return { default: AccountPage }; return { default: AccountPage };
}); });
const AccountEventsPage = lazy(async () => { const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events"); const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage }; return { default: AccountEventsPage };
}); });
const AccountOperationsPage = lazy(async () => { const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations"); const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage }; return { default: AccountOperationsPage };
}); });
const AccountIncomePage = lazy(async () => { const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income"); const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage }; return { default: AccountIncomePage };
}); });
const AccountsPage = lazy(async () => { const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts"); const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage }; return { default: AccountsPage };
}); });
const LoginPage = lazy(async () => { const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login"); const { LoginPage } = await import("@/pages/login");
return { default: LoginPage }; return { default: LoginPage };
}); });
const NotFoundPage = lazy(async () => { const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found"); const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage }; return { default: NotFoundPage };
}); });
const RegisterPage = lazy(async () => { const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register"); const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage }; return { default: RegisterPage };
}); });
const SettingsPage = lazy(async () => { const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings"); const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage }; return { default: SettingsPage };
}); });
const ProtectedLayout = lazy(async () => { const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout"); const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout }; return { default: ProtectedLayout };
}); });
const AppRoutes: FC = () => ( const AppRoutes: FC = () => (
<Routes> <Routes>
<Route element={<Navigate replace to="/accounts" />} path="/" /> <Route element={<Navigate replace to="/accounts" />} path="/" />
<Route <Route
element={( element={(
<Public> <Public>
@@ -84,6 +116,7 @@ const AppRoutes: FC = () => (
)} )}
path="/login" path="/login"
/> />
<Route <Route
element={( element={(
<Public> <Public>
@@ -94,6 +127,7 @@ const AppRoutes: FC = () => (
)} )}
path="/register" path="/register"
/> />
<Route <Route
element={( element={(
<Protected> <Protected>
@@ -111,6 +145,7 @@ const AppRoutes: FC = () => (
)} )}
path="/cards/:cardId" path="/cards/:cardId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -119,6 +154,7 @@ const AppRoutes: FC = () => (
)} )}
path="/cards" path="/cards"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -127,6 +163,7 @@ const AppRoutes: FC = () => (
)} )}
path="/bonds" path="/bonds"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -135,6 +172,7 @@ const AppRoutes: FC = () => (
)} )}
path="/bonds/:bondId" path="/bonds/:bondId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -143,6 +181,7 @@ const AppRoutes: FC = () => (
)} )}
path="/stocks" path="/stocks"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -151,6 +190,7 @@ const AppRoutes: FC = () => (
)} )}
path="/stocks/:stockId" path="/stocks/:stockId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -159,6 +199,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts" path="/accounts"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -167,6 +208,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all" path="/accounts/all"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -175,6 +217,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/operations" path="/accounts/all/operations"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -183,6 +226,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/income" path="/accounts/all/income"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -191,6 +235,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/all/events" path="/accounts/all/events"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -199,6 +244,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId" path="/accounts/:accountId"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -207,6 +253,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/events" path="/accounts/:accountId/events"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -215,6 +262,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/income" path="/accounts/:accountId/income"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -223,6 +271,7 @@ const AppRoutes: FC = () => (
)} )}
path="/accounts/:accountId/operations" path="/accounts/:accountId/operations"
/> />
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -232,6 +281,7 @@ const AppRoutes: FC = () => (
path="/settings" path="/settings"
/> />
</Route> </Route>
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
@@ -21,11 +21,13 @@ import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => { const Navigation: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<button onClick={() => navigate("/accounts/all/income")} type="button"> <button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета Все счета
</button> </button>
<button onClick={() => navigate(-1)} type="button"> <button onClick={() => navigate(-1)} type="button">
Назад Назад
</button> </button>
@@ -39,6 +41,7 @@ const renderTitle = (path: string) =>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<AuthProvider> <AuthProvider>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<Navigation /> <Navigation />
</AuthProvider> </AuthProvider>
</MemoryRouter> </MemoryRouter>
@@ -59,10 +62,13 @@ beforeEach(() => {
describe("document titles", () => { describe("document titles", () => {
it("updates a loading account name and follows account scope and history", async () => { it("updates a loading account name and follows account scope and history", async () => {
const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers<void>(); const { promise: accountsReady, resolve: resolveAccounts }
= Promise.withResolvers<void>();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", async () => { http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady; await accountsReady;
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }])); return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}), }),
); );
@@ -102,6 +108,7 @@ describe("document titles", () => {
server.use( server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => { http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
isRequested = true; isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}), }),
); );
+14 -1
View File
@@ -24,29 +24,36 @@ type AccountDocumentTitleProps = {
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => { const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBrokerageAccounts(); const { data } = useBrokerageAccounts();
const accountName const accountName
= props.accountId === "all" = props.accountId === "all"
? t("accounts.all") ? t("accounts.all")
: (data?.find(account => account.id === props.accountId)?.name : (data?.find(account => account.id === props.accountId)?.name
?? t("account.title")); ?? t("account.title"));
let section = t("portfolio.title"); let section = t("portfolio.title");
switch (props.section ?? "") { switch (props.section ?? "") {
case "income": { case "income": {
section = t("income.title"); section = t("income.title");
break; break;
} }
case "operations": { case "operations": {
section = t("ledger.title"); section = t("ledger.title");
break; break;
} }
case "events": { case "events": {
section = t("events.title"); section = t("events.title");
break; break;
} }
default: { default: {
break; break;
} }
} }
return <DocumentTitle title={`${section} — ${accountName}`} />; return <DocumentTitle title={`${section} — ${accountName}`} />;
}; };
@@ -55,18 +62,21 @@ type InstrumentDocumentTitleProps = { identifier: string };
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBond(props.identifier); const { data } = useBond(props.identifier);
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />; return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
}; };
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useStock(props.identifier); const { data } = useStock(props.identifier);
return <DocumentTitle title={data?.name ?? t("stock.share")} />; return <DocumentTitle title={data?.name ?? t("stock.share")} />;
}; };
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => { const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useBankCard(props.identifier); const { data } = useBankCard(props.identifier);
return <DocumentTitle title={data?.name ?? t("bank.card")} />; return <DocumentTitle title={data?.name ?? t("bank.card")} />;
}; };
@@ -94,7 +104,10 @@ const ProtectedDocumentTitle: FC = () => {
if (cards) return <DocumentTitle title={t("navigation.cards")} />; if (cards) return <DocumentTitle title={t("navigation.cards")} />;
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />; if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />; if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
return settings
? <DocumentTitle title={t("navigation.settings")} />
: <DocumentTitle title={t("navigation.accounts")} />;
}; };
export { ProtectedDocumentTitle }; export { ProtectedDocumentTitle };
1
+1
View File
@@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => {
return ( return (
<> <>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<AppShell contentAboveOutlet={<AppBreadcrumbs />} /> <AppShell contentAboveOutlet={<AppBreadcrumbs />} />
</> </>
); );
@@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary";
const session = { isAuthenticated: false }; const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({ vi.mock("@/features/auth", () => ({
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ user: session.isAuthenticated ? {} : null } useAuth: () =>
/* eslint-enable unicorn/no-null */)), ({ user: session.isAuthenticated ? {} : null }),
})); }));
const ThrowingRoute: FC = () => { const ThrowingRoute: FC = () => {
@@ -24,9 +25,7 @@ const HealthyRoute: FC = () => {
? ( ? (
"healthy route" "healthy route"
) )
: ( : <ThrowingRoute />}
<ThrowingRoute />
)}
</div> </div>
); );
}; };
@@ -39,6 +38,7 @@ const RouteHarness: FC = () => {
<button onClick={() => navigate("/healthy")} type="button"> <button onClick={() => navigate("/healthy")} type="button">
go healthy go healthy
</button> </button>
<RouteErrorBoundary> <RouteErrorBoundary>
<HealthyRoute /> <HealthyRoute />
</RouteErrorBoundary> </RouteErrorBoundary>
@@ -62,10 +62,13 @@ describe("RouteErrorBoundary", () => {
async (path) => { async (path) => {
session.isAuthenticated = path === "/accounts"; session.isAuthenticated = path === "/accounts";
let isFailing = true; let isFailing = true;
const RecoverableRoute: FC = () => { const RecoverableRoute: FC = () => {
if (isFailing) throw new Error("temporary failure"); if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>; return <div>recovered route</div>;
}; };
render( render(
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
@@ -116,10 +119,12 @@ describe("RouteErrorBoundary", () => {
expect(reload).toHaveAttribute("href", "/login"); expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it. // Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let isIntercepted = true; let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => { const preventNavigation = (event: MouseEvent) => {
isIntercepted = event.defaultPrevented; isIntercepted = event.defaultPrevented;
event.preventDefault(); event.preventDefault();
}; };
document.addEventListener("click", preventNavigation, { once: true }); document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload); fireEvent.click(reload);
expect(isIntercepted).toBe(false); expect(isIntercepted).toBe(false);
@@ -33,7 +33,11 @@ class Boundary extends Component<BoundaryProps, State> {
} }
render() { render() {
return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children; return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} />
: (
this.props.children
);
} }
} }
@@ -46,10 +50,13 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
return ( return (
<Stack> <Stack>
<Title order={1}>{t("routeError.title")}</Title> <Title order={1}>{t("routeError.title")}</Title>
<Text>{t("routeError.text")}</Text> <Text>{t("routeError.text")}</Text>
<Button component={Link} to={to}> <Button component={Link} to={to}>
{t("routeError.action")} {t("routeError.action")}
</Button> </Button>
<Button component="a" href={to} variant="default"> <Button component="a" href={to} variant="default">
{t("routeError.reload")} {t("routeError.reload")}
</Button> </Button>
+14 -4
View File
@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
import { RouteTransition } from "./RouteTransition"; import { RouteTransition } from "./RouteTransition";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>; type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
type RouteSkeletonVariant = "auth" | "content" | "shell";
type RouteSkeletonVariant = "auth"
| "content"
| "shell";
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props; const { variant } = props;
@@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
role="status" role="status"
> >
<Skeleton height={32} width="45%" /> <Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" /> <Skeleton height={36} radius="sm" />
</Stack> </Stack>
</Container> </Container>
@@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
> >
<Group justify="space-between" p="md"> <Group justify="space-between" p="md">
<Skeleton height={28} width={160} /> <Skeleton height={28} width={160} />
<Skeleton height={28} width={96} /> <Skeleton height={28} width={96} />
</Group> </Group>
<Container fluid p="md"> <Container fluid p="md">
<Skeleton height={20} width="30%" /> <Skeleton height={20} width="30%" />
<Stack mt="lg"> <Stack mt="lg">
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </Stack>
</Container> </Container>
@@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
return ( return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status"> <Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </Stack>
); );
@@ -61,15 +72,14 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const LazyPage: FC<LazyPageProps> = (props) => { const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props; const { children, variant } = props;
return ( return (
<Suspense fallback={<RouteSkeleton variant={variant} />}> <Suspense fallback={<RouteSkeleton variant={variant} />}>
{variant === "shell" {variant === "shell"
? ( ? (
children children
) )
: ( : <RouteTransition>{children}</RouteTransition>}
<RouteTransition>{children}</RouteTransition>
)}
</Suspense> </Suspense>
); );
}; };
+7 -4
View File
@@ -11,10 +11,9 @@ const Protected: FC<PropsWithChildren> = (props) => {
const { ready, user } = useAuth(); const { ready, user } = useAuth();
const location = useLocation(); const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
return user return user
? ( ? <>{children}</>
<>{children}</>
)
: ( : (
<Navigate <Navigate
replace replace
@@ -30,13 +29,17 @@ const Public: FC<PropsWithChildren> = (props) => {
const location = useLocation(); const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null | { returnTo?: string })?.returnTo;
const requested = (location.state as null
| { returnTo?: string })?.returnTo;
const returnTo const returnTo
= typeof requested === "string" = typeof requested === "string"
&& requested.startsWith("/") && requested.startsWith("/")
&& !requested.startsWith("//") && !requested.startsWith("//")
? requested ? requested
: "/accounts"; : "/accounts";
return user ? <Navigate replace to={returnTo} /> : <>{children}</>; return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
}; };
+6 -4
View File
@@ -11,16 +11,18 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const transition = {
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut" as const,
};
return ( return (
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>
<m.div <m.div
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
initial={!reduceMotion && { opacity: 0 }} initial={!reduceMotion && { opacity: 0 }}
key={pathname} key={pathname}
transition={{ transition={transition}
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut",
}}
> >
{children} {children}
</m.div> </m.div>
@@ -8,19 +8,24 @@ import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = { type AccountBreadcrumbsProps = {
accountId: string; accountId: string;
section?: "events" | "income" | "operations"; section?: "events"
| "income"
| "operations";
}; };
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => { const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props; const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const accountName const accountName
= accountId === "all" = accountId === "all"
? t("accounts.all") ? t("accounts.all")
: (accounts?.find(account => account.id === accountId)?.name : (accounts?.find(account => account.id === accountId)?.name
?? t("breadcrumbs.accountFallback")); ?? t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`; const accountPath = `/accounts/${accountId}`;
const items = [ const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" }, { label: t("breadcrumbs.accounts"), to: "/accounts" },
section ? { label: accountName, to: accountPath } : { label: accountName }, section ? { label: accountName, to: accountPath } : { label: accountName },
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useAccountScopeSwitchVm } from "./AccountScopeSwitch.vm";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const { accountId, data, onChange, t } = useAccountScopeSwitchVm(props);
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={onChange}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch };
@@ -1,6 +1,3 @@
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
@@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string }; type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => { function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) {
const { accountId, section } = props; const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation(); const { search } = useLocation();
const data = [ const data = [
{ label: t("accounts.all"), value: "all" }, { label: t("accounts.all"), value: "all" },
...(accounts ?? []).map(account => ({ ...(accounts ?? []).map(account => ({
@@ -21,27 +19,23 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
value: account.id, value: account.id,
})), })),
]; ];
if (data.every(item => item.value !== accountId)) if (data.every(item => item.value !== accountId))
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId }); data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
search: filters.toString(),
});
}}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch }; const onChange: (value: null | string) => void = (value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section]
.filter(Boolean)
.join("/"),
search: filters.toString(),
});
};
return { accountId, data, onChange, t };
}
export { useAccountScopeSwitchVm };
@@ -16,6 +16,7 @@ function renderBreadcrumbs(
accounts?: { id: string; name: string }[], accounts?: { id: string; name: string }[],
) { ) {
useBrokerageAccounts.mockReturnValue({ data: accounts }); useBrokerageAccounts.mockReturnValue({ data: accounts });
return render( return render(
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
@@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => {
"page", "page",
); );
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3"); expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
for (const separator of screen.getAllByText("→")) { for (const separator of screen.getAllByText("→")) {
expect(separator).toHaveAttribute("aria-hidden", "true"); expect(separator).toHaveAttribute("aria-hidden", "true");
} }
+11 -2
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs"; import { CardBreadcrumbs } from "./CardBreadcrumbs";
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const operations = useMatch({ const operations = useMatch({
end: true, end: true,
path: "/accounts/:accountId/operations", path: "/accounts/:accountId/operations",
}); });
const events = useMatch({ end: true, path: "/accounts/:accountId/events" }); const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" }); const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" }); const account = useMatch({ end: true, path: "/accounts/:accountId" });
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
accountId={operations.params.accountId ?? ""} accountId={operations.params.accountId ?? ""}
section="operations" section="operations"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={operations.params.accountId ?? ""} accountId={operations.params.accountId ?? ""}
section="operations" section="operations"
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
accountId={events.params.accountId ?? ""} accountId={events.params.accountId ?? ""}
section="events" section="events"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={events.params.accountId ?? ""} accountId={events.params.accountId ?? ""}
section="events" section="events"
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
accountId={income.params.accountId ?? ""} accountId={income.params.accountId ?? ""}
section="income" section="income"
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={income.params.accountId ?? ""} accountId={income.params.accountId ?? ""}
section="income" section="income"
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} /> <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} /> <AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack> </Stack>
); );
@@ -80,7 +86,10 @@ const AppBreadcrumbs: FC = () => {
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
return settings
? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
: undefined;
}; };
export { AppBreadcrumbs }; export { AppBreadcrumbs };
@@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string }; type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => { const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier); const { data } = useBond(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
@@ -7,11 +7,13 @@ import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css"; import classes from "./AppBreadcrumbs.module.css";
type BreadcrumbTrailProps = { items: Crumb[] }; type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string }; type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => { const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props; const { items } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Breadcrumbs <Breadcrumbs
aria-label={t("breadcrumbs.label")} aria-label={t("breadcrumbs.label")}
@@ -7,9 +7,11 @@ import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string }; type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => { const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier); const { data } = useBankCard(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
@@ -7,9 +7,11 @@ import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string }; type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => { const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier); const { data } = useStock(props.identifier);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<BreadcrumbTrail <BreadcrumbTrail
items={[ items={[
-178
View File
@@ -30,31 +30,6 @@
} }
} }
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.stocksIcon { .stocksIcon {
color: var(--mantine-color-green-8); color: var(--mantine-color-green-8);
} }
@@ -67,163 +42,10 @@
color: var(--mantine-color-gray-7); color: var(--mantine-color-gray-7);
} }
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.navLink { .navLink {
transition: background-color 180ms ease-in-out; transition: background-color 180ms ease-in-out;
} }
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
} }
@media (max-width: 47.99375em) { @media (max-width: 47.99375em) {
+33 -80
View File
@@ -1,11 +1,16 @@
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core"; import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import {
BondsIcon,
CardsIcon,
PortfolioIcon,
SettingsIcon,
StocksIcon,
} from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router"; import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css"; import classes from "./AppShell.module.css";
type AppShellProps = { type AppShellProps = {
@@ -16,80 +21,34 @@ const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props; const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const items = [ const items = [
{ {
icon: ( icon: <PortfolioIcon size={20} />,
<>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</>
),
iconClassName: classes.portfolioIcon, iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"), label: t("navigation.accounts"),
to: "/accounts", to: "/accounts",
}, },
{ {
icon: ( icon: <CardsIcon size={20} />,
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</>
),
iconClassName: classes.cardsIcon, iconClassName: classes.cardsIcon,
label: t("navigation.cards"), label: t("navigation.cards"),
to: "/cards", to: "/cards",
}, },
{ {
icon: ( icon: <StocksIcon size={20} />,
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
iconClassName: classes.stocksIcon, iconClassName: classes.stocksIcon,
label: t("navigation.stocks"), label: t("navigation.stocks"),
to: "/stocks", to: "/stocks",
}, },
{ {
icon: ( icon: <BondsIcon size={20} />,
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</>
),
iconClassName: classes.bondsIcon, iconClassName: classes.bondsIcon,
label: t("navigation.bonds"), label: t("navigation.bonds"),
to: "/bonds", to: "/bonds",
}, },
{ {
icon: ( icon: <SettingsIcon size={20} />,
<>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
iconClassName: classes.settingsIcon, iconClassName: classes.settingsIcon,
label: t("navigation.settings"), label: t("navigation.settings"),
to: "/settings", to: "/settings",
@@ -105,6 +64,7 @@ const AppShell: FC<AppShellProps> = (props) => {
<MantineAppShell.Header className={classes.header}> <MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text> <Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header> </MantineAppShell.Header>
<MantineAppShell.Navbar p="sm"> <MantineAppShell.Navbar p="sm">
{items.map(item => ( {items.map(item => (
<NavLink <NavLink
@@ -117,45 +77,38 @@ const AppShell: FC<AppShellProps> = (props) => {
<span <span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`} className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
> >
<svg {item.icon}
aria-hidden="true"
fill="none"
focusable="false"
height={20}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={20}
>
{item.icon}
</svg>
</span> </span>
)} )}
to={item.to} to={item.to}
/> />
))} ))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}> <MantineAppShell.Main className={classes.main}>
<div className={classes.content}> <div className={classes.content}>
{contentAboveOutlet} {contentAboveOutlet}
<Outlet /> <Outlet />
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}> <nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map(item => ( {items.map((item) => {
<RouterNavLink const appearanceClass = pathname.startsWith(item.to)
className={ ? classes.active
(pathname.startsWith(item.to)) ? (classes.active) : undefined : undefined;
}
key={item.to} return (
to={item.to} <RouterNavLink
> className={appearanceClass}
{item.label} key={item.to}
</RouterNavLink> to={item.to}
))} >
{item.label}
</RouterNavLink>
);
})}
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
+41 -8
View File
@@ -19,8 +19,8 @@ import {
useBankSummary, useBankSummary,
} from "./queries"; } from "./queries";
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ const card: BankCard
Review

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

слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило
{ = {
archivedAt: null, archivedAt: null,
balance: { currency: "RUB", value: "100" }, balance: { currency: "RUB", value: "100" },
bankName: null, bankName: null,
@@ -31,8 +31,8 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-09-01", openingDate: "2026-09-01",
paymentSystem: null, paymentSystem: null,
version: 1, version: 1,
} };
/* eslint-enable unicorn/no-null */);
const transfer: CreateBankOperation = { const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" }, amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a", cardId: "a",
@@ -55,6 +55,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
http.get("*/api/v1/bank-cards", ({ request }) => { http.get("*/api/v1/bank-cards", ({ request }) => {
const key = new URL(request.url).pathname + new URL(request.url).search; const key = new URL(request.url).pathname + new URL(request.url).search;
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
return HttpResponse.json([card]); return HttpResponse.json([card]);
}), }),
http.get("*/api/v1/bank-cards/*", ({ request }) => { http.get("*/api/v1/bank-cards/*", ({ request }) => {
@@ -84,7 +85,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
page: 1, page: 1,
total: 0, total: 0,
}); });
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
return url.pathname.includes("/operations/")
? HttpResponse.json({ ...transfer, version: 1 })
: HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}), }),
]; ];
} }
@@ -94,15 +98,18 @@ function useReadModels() {
const a = useBankCard("a"); const a = useBankCard("a");
const b = useBankCard("b"); const b = useBankCard("b");
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20, limit: 20,
month: "2026-09", month: "2026-09",
page: 2, page: 2,
}); });
const expenses = useBankExpenses("a", { page: 1 }); const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09"); const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b"); const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
mutations: useBankMutations(), mutations: useBankMutations(),
reads: [ reads: [
@@ -134,6 +141,7 @@ describe("banking API", () => {
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("month")).toBe("2026-09");
if (isFail && page === 2) if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 }); return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({ return HttpResponse.json({
hasNext: page === 1, hasNext: page === 1,
items: [{ ...transfer, id: String(page) }], items: [{ ...transfer, id: String(page) }],
@@ -142,10 +150,12 @@ describe("banking API", () => {
}); });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]); expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map(item => item.id)).toEqual([ expect(result.current.data?.items.map(item => item.id)).toEqual([
@@ -153,10 +163,12 @@ describe("banking API", () => {
"2", "2",
]); ]);
isFail = true; isFail = true;
const { result: failedResult } = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(failedResult.current.isError).toBe(true)); await waitFor(() => expect(failedResult.current.isError).toBe(true));
expect(failedResult.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
@@ -164,15 +176,15 @@ describe("banking API", () => {
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use(...readHandlers(counts)); server.use(...readHandlers(counts));
const { result } = renderHook(useReadModels, { const { result } = renderHook(useReadModels, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await waitFor(() => await waitFor(() =>
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
expect(counts.keys()).toContain( expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
"/api/v1/bank-cards?includeArchived=true",
);
expect(counts.keys()).toContain( expect(counts.keys()).toContain(
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
); );
@@ -184,10 +196,13 @@ describe("banking API", () => {
it("replays the exact UUID and large Money string once across shared auth refresh", async () => { it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
let token = "expired"; let token = "expired";
const refresh = vi.fn(() => { const refresh = vi.fn(() => {
token = "renewed"; token = "renewed";
return Promise.resolve(true); return Promise.resolve(true);
}); });
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh }); configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = []; const bodies: unknown[] = [];
const headers: (null | string)[] = []; const headers: (null | string)[] = [];
@@ -195,14 +210,17 @@ describe("banking API", () => {
http.post("*/api/v1/bank-cards/operations", async ({ request }) => { http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json()); bodies.push(await request.json());
headers.push(request.headers.get("Authorization")); headers.push(request.headers.get("Authorization"));
return bodies.length === 1 return bodies.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json({ ...transfer, version: 1 }); : HttpResponse.json({ ...transfer, version: 1 });
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
@@ -214,14 +232,17 @@ describe("banking API", () => {
server.use( server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => { http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json()); bodies.push(await request.json());
return bodies.length === 1 return bodies.length === 1
? HttpResponse.error() ? HttpResponse.error()
: HttpResponse.json({ ...transfer, version: 1 }); : HttpResponse.json({ ...transfer, version: 1 });
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(async () => { await act(async () => {
await expect( await expect(
result.current.createOperation.mutateAsync(transfer), result.current.createOperation.mutateAsync(transfer),
@@ -239,13 +260,16 @@ describe("banking API", () => {
server.use( server.use(
http.post("*/api/v1/bank-cards/operations", async () => { http.post("*/api/v1/bank-cards/operations", async () => {
await pending; await pending;
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }); return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ mutations: useBankMutations(), query: useBankCard("a") }), () => ({ mutations: useBankMutations(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>; let saving: Promise<unknown>;
act(() => { act(() => {
@@ -310,10 +334,12 @@ describe("banking API", () => {
HttpResponse.json({ code }, { status: 409 }), HttpResponse.json({ code }, { status: 409 }),
), ),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }), () => ({ card: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.card.data?.version).toBe(1)); await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => { await act(async () => {
await expect( await expect(
@@ -337,6 +363,7 @@ describe("banking API", () => {
const body = await request.json(); const body = await request.json();
requests.push(body); requests.push(body);
name = "Groceries"; name = "Groceries";
return HttpResponse.json({ return HttpResponse.json({
id: "food", id: "food",
isDefault: true, isDefault: true,
@@ -348,14 +375,17 @@ describe("banking API", () => {
"*/api/v1/bank-cards/operations/original", "*/api/v1/bank-cards/operations/original",
async ({ request }) => { async ({ request }) => {
requests.push(await request.json()); requests.push(await request.json());
return HttpResponse.json({ ...transfer, version: 2 }); return HttpResponse.json({ ...transfer, version: 2 });
}, },
), ),
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => { http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
requests.push(new URL(request.url).searchParams.get("version")); requests.push(new URL(request.url).searchParams.get("version"));
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ () => ({
categories: useBankCategories(), categories: useBankCategories(),
@@ -363,6 +393,7 @@ describe("banking API", () => {
}), }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0].name).toBe("Food"),
); );
@@ -406,9 +437,11 @@ describe("banking API", () => {
), ),
); );
const { queryClient, wrapper } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const { rerender, result } = renderHook(() => useBankCard("a"), { const { rerender, result } = renderHook(() => useBankCard("a"), {
wrapper, wrapper,
}); });
await waitFor(() => expect(result.current.data?.balance.value).toBe("100")); await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
await apiClient.PUT("/api/v1/tbank-connection", { await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "synthetic" }, body: { token: "synthetic" },
+22 -3
View File
@@ -1,6 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import {
apiClient,
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
} from "@/shared/api";
import type { import type {
CreateBankCard, CreateBankCard,
@@ -17,21 +22,27 @@ async function mutationData<T>(
const { data, error } = await request; const { data, error } = await request;
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
return data; return data;
} }
function useBankMutations() { function useBankMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey }) => { predicate: ({ queryKey }) => {
const [method, path] = queryKey; const [method, path] = queryKey;
return method === "get"
return (
method === "get"
&& typeof path === "string" && typeof path === "string"
&& (path === "/api/v1/bank-cards" && (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/")); || path.startsWith("/api/v1/bank-cards/"))
);
}, },
}); });
const options = { const options = {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
@@ -49,11 +60,13 @@ function useBankMutations() {
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
} as const; } as const;
const cardCreation = useMutation({ const cardCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateBankCard) => mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })), mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
}); });
const updateCard = useMutation({ const updateCard = useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
@@ -64,11 +77,13 @@ function useBankMutations() {
}), }),
), ),
}); });
const operationCreation = useMutation({ const operationCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateBankOperation) => mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })), mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
}); });
const updateOperation = useMutation({ const updateOperation = useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
@@ -79,6 +94,7 @@ function useBankMutations() {
}), }),
), ),
}); });
const operationDeletion = useMutation({ const operationDeletion = useMutation({
...options, ...options,
mutationFn: ({ id, version }: { id: string; version: number }) => mutationFn: ({ id, version }: { id: string; version: number }) =>
@@ -88,11 +104,13 @@ function useBankMutations() {
}), }),
), ),
}); });
const categoryCreation = useMutation({ const categoryCreation = useMutation({
...options, ...options,
mutationFn: (body: CreateExpenseCategory) => mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })), mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
}); });
const updateCategory = useMutation({ const updateCategory = useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) => mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
@@ -103,6 +121,7 @@ function useBankMutations() {
}), }),
), ),
}); });
return { return {
createCard: cardCreation, createCard: cardCreation,
createCategory: categoryCreation, createCategory: categoryCreation,
+25 -4
View File
@@ -9,24 +9,34 @@ import { moneyCents } from "../model/form-validation";
type BankCard type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
@@ -41,12 +51,13 @@ function sortBankSummary(data: BankSummary): BankSummary {
if (left === null || right === null) if (left === null || right === null)
throw new Error("Invalid bank summary amount"); throw new Error("Invalid bank summary amount");
if (left > right) return -1; if (left > right) return -1;
return left < right ? 1 : 0; return left < right ? 1 : 0;
}), }),
}; };
} }
function useBankCard(id: string | undefined) { function useBankCard(id?: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", {
params: { path: { id: id ?? "" } }, params: { path: { id: id ?? "" } },
@@ -83,7 +94,7 @@ function useBankExpenses(
}); });
} }
function useBankOperation(id: string | undefined) { function useBankOperation(id?: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
@@ -105,6 +116,7 @@ function useBankOperations(
}, },
) { ) {
const isAll = query.limit === "all"; const isAll = query.limit === "all";
const parameters = { const parameters = {
path: { id: id ?? "" }, path: { id: id ?? "" },
query: { query: {
@@ -113,11 +125,13 @@ function useBankOperations(
page: isAll ? 1 : query.page, page: isAll ? 1 : query.page,
}, },
}; };
const options = apiQueryClient.queryOptions( const options = apiQueryClient.queryOptions(
"get", "get",
"/api/v1/bank-cards/{id}/operations", "/api/v1/bank-cards/{id}/operations",
{ params: parameters }, { params: parameters },
); );
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({ return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))), enabled: Boolean(id && (query.month || (query.from && query.to))),
@@ -126,6 +140,7 @@ function useBankOperations(
previousQuery, previousQuery,
) => { ) => {
const previous = previousQuery?.queryKey[2]?.params; const previous = previousQuery?.queryKey[2]?.params;
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id && previous?.path?.id === id
@@ -138,6 +153,7 @@ function useBankOperations(
}, },
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const items: BankOperation[] = []; const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) { for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET( const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations", "/api/v1/bank-cards/{id}/operations",
@@ -146,6 +162,7 @@ function useBankOperations(
signal, signal,
}, },
); );
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
if (!data) throw new Error("Missing bank operations response"); if (!data) throw new Error("Missing bank operations response");
@@ -154,18 +171,22 @@ function useBankOperations(
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 }; if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
} }
}, },
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey, queryKey: isAll
? ([...options.queryKey, "all"] as const)
: options.queryKey,
}); });
} }
function useBankSummary( function useBankSummary(
period: string | { from: string; to: string }, period: string
| { from: string; to: string },
cardId?: string, cardId?: string,
) { ) {
const query const query
= typeof period === "string" = typeof period === "string"
? { cardId, month: period } ? { cardId, month: period }
: { ...period, cardId }; : { ...period, cardId };
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query }, params: { query },
@@ -28,6 +28,7 @@ it("orders displayed expenses descending, preserving cents, ties and source data
net: money("0"), net: money("0"),
refunds: money("0"), refunds: money("0"),
}; };
expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual( expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
["3", "1", "2", "5", "4", "0"], ["3", "1", "2", "5", "4", "0"],
); );
+5 -10
View File
@@ -10,21 +10,16 @@ export {
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
} from "./api/queries"; } from "./api/queries";
export { export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
bankDateInputValue, export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
bankDateSchema,
bankMoneyValue, export { bankOperationAppearance } from "./model/operation-appearance";
bankToday,
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { bankOperationKinds } from "./model/operation-kinds"; export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period"; export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback"; export { BankFeedback } from "./ui/BankFeedback";
export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
export { CategoryManager } from "./ui/CategoryManager/CategoryManager"; export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
export { useBankFormFocus } from "./ui/useBankFormFocus"; export { useBankFormFocus } from "./ui/useBankFormFocus";
+8
View File
@@ -0,0 +1,8 @@
enum BankName {
AlfaBank = "Альфа-Банк",
Sberbank = "Сбербанк",
TBank = "Т-Банк",
Vtb = "ВТБ",
}
export { BankName };
@@ -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 };

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