refactor: enforce frontend view model boundaries
This commit is contained in:
+5
-4
@@ -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`.
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
|
|||||||
@@ -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 иллюстрирует собственный
|
||||||
|
|||||||
@@ -17,6 +17,30 @@
|
|||||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||||
|
|
||||||
|
## Читаемость: ESLint и review
|
||||||
|
|
||||||
|
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||||
|
JSX siblings и вокруг многострочных declarations/control-flow/return, вынос
|
||||||
|
функций и условных выражений из JSX props, отдельные Zod schema files,
|
||||||
|
отсутствие SVG в приложении, optional trailing parameters и многострочные
|
||||||
|
сложные union types. Generated artifacts не форматируй вручную.
|
||||||
|
URL, строковые/template/regex literals и комментарии исключены из `max-len`;
|
||||||
|
это исключение для содержимого, а не повод собирать длинные expressions.
|
||||||
|
CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки.
|
||||||
|
|
||||||
|
Review проверяет то, чего синтаксис не доказывает:
|
||||||
|
|
||||||
|
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||||
|
- Имена derived values описывают значение, например `projectedAmount`.
|
||||||
|
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||||
|
и многострочное преобразование — отдельная функция с guard и `if/else`.
|
||||||
|
- Union из object shapes оформляй несколькими строками; nullable scalar
|
||||||
|
`null | string` остаётся компактным. Для повторяемого shape используй named type.
|
||||||
|
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
|
||||||
|
перед следующим required параметром может сохранять `T | undefined`.
|
||||||
|
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
|
||||||
|
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
|
||||||
|
|
||||||
## Hooks и derived state
|
## Hooks и derived state
|
||||||
|
|
||||||
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
|
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
|
||||||
|
|||||||
+29
-11
@@ -11,13 +11,11 @@ Mantine component и общую тему. Для выпадающего выбо
|
|||||||
ограничения JSDOM решай в test setup, сохраняя production UI.
|
ограничения JSDOM решай в test setup, сохраняя production UI.
|
||||||
Семантический HTML для структуры страницы остаётся уместным.
|
Семантический HTML для структуры страницы остаётся уместным.
|
||||||
|
|
||||||
Icons ищи у существующего owner (например,
|
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
|
||||||
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
|
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
|
||||||
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
|
иконки из model сущности. SVG декоративны по умолчанию; доступное имя action
|
||||||
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
|
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
|
||||||
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
|
и доступное имя. Приложение и Storybook подключают CSS пакета.
|
||||||
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
|
|
||||||
components живут у feature/entity/page, даже при повторном использовании.
|
|
||||||
|
|
||||||
Wrapper нужен для проектной ответственности, а не ради нового имени
|
Wrapper нужен для проектной ответственности, а не ради нового имени
|
||||||
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
||||||
@@ -51,10 +49,26 @@ retry callbacks, domain grouping и derived presentation data. View получа
|
|||||||
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
|
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
|
||||||
| `Component.module.css` | Локальные стили |
|
| `Component.module.css` | Локальные стили |
|
||||||
|
|
||||||
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
|
View получает готовые данные и именованные действия. Container вызывает VM;
|
||||||
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
|
VM владеет запросами, формой, состоянием, обработчиками и derived values.
|
||||||
image fallback) и formatter одного переданного значения. Группировка/доли
|
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
|
||||||
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
|
вызывает container; presentation hook не должен скрывать fetching во view.
|
||||||
|
Presentation VM для form-context/UI-only state может использоваться автономно
|
||||||
|
в каталоге: её граф зависимостей не достигает application data/API.
|
||||||
|
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||||
|
|
||||||
|
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||||
|
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||||
|
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||||
|
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
|
||||||
|
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
|
||||||
|
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
|
||||||
|
|
||||||
|
Внутри компонента нет именованных helpers. Чистые функции форматирования и
|
||||||
|
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
|
||||||
|
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
|
||||||
|
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
||||||
|
Render-helper замени самостоятельным presentation component у того же owner.
|
||||||
|
|
||||||
Отдельный state/section с самостоятельной responsibility выноси в
|
Отдельный state/section с самостоятельной responsibility выноси в
|
||||||
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
||||||
@@ -87,6 +101,10 @@ image fallback) и formatter одного переданного значени
|
|||||||
|
|
||||||
## Формы
|
## Формы
|
||||||
|
|
||||||
|
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
|
||||||
|
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
|
||||||
|
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
|
||||||
|
|
||||||
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
|
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
|
||||||
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
|
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
|
||||||
Во время submit блокируй все действия формы. Zod errors показывай у поля;
|
Во время submit блокируй все действия формы. Zod errors показывай у поля;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module.exports = {
|
|||||||
name: "stories-must-not-reach-application-data",
|
name: "stories-must-not-reach-application-data",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: {
|
to: {
|
||||||
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
|
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const readability = {
|
||||||
|
create(context) {
|
||||||
|
const filename = context.filename;
|
||||||
|
|
||||||
|
function checkParameters(node) {
|
||||||
|
const last = node.params.at(-1);
|
||||||
|
if (
|
||||||
|
last?.type === "Identifier"
|
||||||
|
&& !last.optional
|
||||||
|
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
|
||||||
|
&& last.typeAnnotation.typeAnnotation.types.some(
|
||||||
|
type => type.type === "TSUndefinedKeyword",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.report({ messageId: "optional", node: last });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ArrowFunctionExpression: checkParameters,
|
||||||
|
CallExpression(node) {
|
||||||
|
if (
|
||||||
|
node.callee.type === "MemberExpression"
|
||||||
|
&& ["refine", "superRefine"].includes(node.callee.property.name)
|
||||||
|
&& !filename.endsWith(".schema.ts")
|
||||||
|
)
|
||||||
|
context.report({ messageId: "schema", node });
|
||||||
|
},
|
||||||
|
FunctionDeclaration: checkParameters,
|
||||||
|
FunctionExpression: checkParameters,
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
if (
|
||||||
|
node.source.value === "zod"
|
||||||
|
&& node.importKind !== "type"
|
||||||
|
&& !filename.endsWith(".schema.ts")
|
||||||
|
)
|
||||||
|
context.report({ messageId: "schema", node });
|
||||||
|
},
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
if (
|
||||||
|
!filename.includes("/packages/icons/")
|
||||||
|
&& node.name.type === "JSXIdentifier"
|
||||||
|
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
|
||||||
|
node.name.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.report({ messageId: "svg", node });
|
||||||
|
},
|
||||||
|
TSUnionType(node) {
|
||||||
|
const complex
|
||||||
|
= node.types.length > 2
|
||||||
|
|| node.types.some(type => type.type === "TSTypeLiteral");
|
||||||
|
|
||||||
|
if (
|
||||||
|
complex
|
||||||
|
&& node.types.some(
|
||||||
|
(type, index) =>
|
||||||
|
index > 0
|
||||||
|
&& type.loc.start.line === node.types[index - 1].loc.end.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.report({ messageId: "union", node });
|
||||||
|
},
|
||||||
|
VariableDeclarator(node) {
|
||||||
|
if (
|
||||||
|
!/^[A-Z]/.test(node.id.name ?? "")
|
||||||
|
|| node.init?.type !== "ArrowFunctionExpression"
|
||||||
|
|| node.init.body.type !== "BlockStatement"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const statement of node.init.body.body) {
|
||||||
|
if (statement.type === "FunctionDeclaration")
|
||||||
|
context.report({ messageId: "helper", node: statement });
|
||||||
|
|
||||||
|
else if (statement.type === "VariableDeclaration") {
|
||||||
|
for (const declaration of statement.declarations) {
|
||||||
|
if (
|
||||||
|
["ArrowFunctionExpression", "FunctionExpression"].includes(
|
||||||
|
declaration.init?.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.report({ messageId: "helper", node: declaration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
messages: {
|
||||||
|
helper:
|
||||||
|
"Move helpers outside components; actions belong to the ViewModel.",
|
||||||
|
optional:
|
||||||
|
"Use an optional trailing parameter instead of a union with undefined.",
|
||||||
|
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||||
|
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||||
|
union: "Put complex union members on separate lines.",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
type: "suggestion",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isComponent(node) {
|
||||||
|
const pending = [node];
|
||||||
|
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const child = pending.pop();
|
||||||
|
if (!child) continue;
|
||||||
|
|
||||||
|
switch (child.type) {
|
||||||
|
case "JSXElement": {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "JSXFragment": {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "JSXExpressionContainer": {
|
||||||
|
pending.push(child.expression);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "LogicalExpression": {
|
||||||
|
pending.push(child.right);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ConditionalExpression": {
|
||||||
|
pending.push(child.consequent, child.alternate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxSpacing = {
|
||||||
|
create(context) {
|
||||||
|
function check(node) {
|
||||||
|
let previous;
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === "JSXText" && !child.value.trim()) continue;
|
||||||
|
if (isComponent(child)) {
|
||||||
|
if (previous) {
|
||||||
|
const gap = context.sourceCode.text.slice(
|
||||||
|
previous.range[1],
|
||||||
|
child.range[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!/\n\s*\n/.test(gap))
|
||||||
|
context.report({
|
||||||
|
fix: fixer => fixer.insertTextAfter(previous, "\n"),
|
||||||
|
messageId: "space",
|
||||||
|
node: child,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = child;
|
||||||
|
}
|
||||||
|
else previous = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { JSXElement: check, JSXFragment: check };
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fixable: "whitespace",
|
||||||
|
messages: { space: "Separate sibling JSX components with a blank line." },
|
||||||
|
schema: [],
|
||||||
|
type: "layout",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
|
||||||
+163
-43
@@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
|||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
import frontend from "./eslint-rules.mjs";
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
|
plugins: { frontend },
|
||||||
|
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
|
||||||
|
},
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
|
"**/dist/**",
|
||||||
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
|
"dist-ssr/**",
|
||||||
|
"storybook-static/**",
|
||||||
|
"coverage/**",
|
||||||
|
".vitest/**",
|
||||||
|
".scannerwork/**",
|
||||||
|
"src/shared/api/generated/**",
|
||||||
|
"src/shared/api/spec/**",
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
extends: [
|
extends: [
|
||||||
@@ -35,32 +49,91 @@ export default defineConfig(
|
|||||||
security.configs.recommended,
|
security.configs.recommended,
|
||||||
noUnsanitized.configs.recommended,
|
noUnsanitized.configs.recommended,
|
||||||
perfectionist.configs["recommended-natural"],
|
perfectionist.configs["recommended-natural"],
|
||||||
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
|
stylistic.configs.customize({
|
||||||
|
indent: "tab",
|
||||||
|
jsx: true,
|
||||||
|
quotes: "double",
|
||||||
|
semi: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
||||||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||||
rules: {
|
rules: {
|
||||||
// A comma disambiguates generic arrow functions from JSX in TSX.
|
// A comma disambiguates generic arrow functions from JSX in TSX.
|
||||||
"@stylistic/comma-dangle": ["error", {
|
"@stylistic/comma-dangle": [
|
||||||
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
|
"error",
|
||||||
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
|
{
|
||||||
objects: "always-multiline", tuples: "always-multiline",
|
arrays: "always-multiline",
|
||||||
}],
|
enums: "always-multiline",
|
||||||
|
exports: "always-multiline",
|
||||||
|
functions: "always-multiline",
|
||||||
|
generics: "ignore",
|
||||||
|
imports: "always-multiline",
|
||||||
|
objects: "always-multiline",
|
||||||
|
tuples: "always-multiline",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
// Keep text/interpolation together: splitting can change rendered whitespace.
|
// Keep text/interpolation together: splitting can change rendered whitespace.
|
||||||
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
||||||
|
"@stylistic/max-len": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
code: 80,
|
||||||
|
ignoreComments: true,
|
||||||
|
ignoreRegExpLiterals: true,
|
||||||
|
ignoreStrings: true,
|
||||||
|
ignoreTemplateLiterals: true,
|
||||||
|
ignoreTrailingComments: true,
|
||||||
|
ignoreUrls: true,
|
||||||
|
tabWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
||||||
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
||||||
"@stylistic/operator-linebreak": ["error", "before"],
|
"@stylistic/operator-linebreak": ["error", "before"],
|
||||||
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
|
"@stylistic/padding-line-between-statements": [
|
||||||
|
"error",
|
||||||
|
{ blankLine: "always", next: "return", prev: "*" },
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
next: "*",
|
||||||
|
prev: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
next: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||||
|
prev: "*",
|
||||||
|
},
|
||||||
|
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
|
||||||
|
],
|
||||||
|
"no-nested-ternary": "error",
|
||||||
|
"perfectionist/sort-classes": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
groups: ["static-method", "property", "method", "unknown"],
|
||||||
|
type: "natural",
|
||||||
|
},
|
||||||
|
],
|
||||||
// Preserve the repository's component, hook and config file naming conventions.
|
// Preserve the repository's component, hook and config file naming conventions.
|
||||||
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }],
|
"unicorn/filename-case": [
|
||||||
|
"error",
|
||||||
|
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
|
||||||
|
],
|
||||||
// React uses Props/Ref names, including the recommended React ref-name rule.
|
// React uses Props/Ref names, including the recommended React ref-name rule.
|
||||||
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }],
|
"unicorn/name-replacements": [
|
||||||
|
"error",
|
||||||
|
{ replacements: { props: false, ref: false } },
|
||||||
|
],
|
||||||
// React, Mantine and Fetch accept null at their documented call boundaries.
|
// React, Mantine and Fetch accept null at their documented call boundaries.
|
||||||
"unicorn/no-null": ["error", { checkArguments: false }],
|
"unicorn/no-null": ["error", { checkArguments: false }],
|
||||||
|
"unicorn/prefer-ternary": ["error", "only-single-line"],
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })],
|
"import-x/resolver-next": [
|
||||||
|
createTypeScriptImportResolver({ project: "tsconfig.json" }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,18 +141,37 @@ export default defineConfig(
|
|||||||
languageOptions: { globals: globals.node },
|
languageOptions: { globals: globals.node },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
extends: [
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
importX.flatConfigs.typescript,
|
||||||
|
],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
],
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
|
extends: [
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
|
react.configs["recommended-type-checked"],
|
||||||
|
hooks.configs.flat.recommended,
|
||||||
|
query.configs["flat/recommended"],
|
||||||
|
],
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"packages/*/src/**/*.{ts,tsx}",
|
||||||
|
".storybook/preview.tsx",
|
||||||
|
],
|
||||||
languageOptions: { globals: globals.browser },
|
languageOptions: { globals: globals.browser },
|
||||||
rules: {
|
rules: {
|
||||||
"import-x/exports-last": "error",
|
"import-x/exports-last": "error",
|
||||||
@@ -95,10 +187,16 @@ export default defineConfig(
|
|||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-magic-numbers": ["error", {
|
"@typescript-eslint/no-magic-numbers": [
|
||||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
|
"error",
|
||||||
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
|
{
|
||||||
}],
|
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
|
||||||
|
ignoreArrayIndexes: true,
|
||||||
|
ignoreDefaultValues: true,
|
||||||
|
ignoreNumericLiteralTypes: true,
|
||||||
|
ignoreTypeIndexes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,51 +208,73 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
|
extends: [
|
||||||
|
testingLibrary.configs["flat/react"],
|
||||||
|
jestDom.configs["flat/recommended"],
|
||||||
|
],
|
||||||
files: ["**/*.test.tsx"],
|
files: ["**/*.test.tsx"],
|
||||||
},
|
},
|
||||||
storybook.configs["flat/recommended"],
|
storybook.configs["flat/recommended"],
|
||||||
{
|
{
|
||||||
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
||||||
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
|
rules: {
|
||||||
|
"import-x/exports-last": "off",
|
||||||
|
"import-x/no-default-export": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
"src/**/*.{ts,tsx}",
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
"packages/*/src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
ignores: [
|
|
||||||
"**/*.test.{ts,tsx}",
|
|
||||||
"**/*.stories.tsx",
|
|
||||||
"src/test/**",
|
|
||||||
],
|
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
message: "Use the owning enum member for machine codes and protocol values.",
|
message:
|
||||||
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
|
||||||
|
selector:
|
||||||
|
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use the owning enum member instead of a template machine code.",
|
message: "Name the derived prop value before JSX.",
|
||||||
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
selector:
|
||||||
|
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Use the owning enum member for machine codes and protocol values.",
|
||||||
|
selector:
|
||||||
|
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use an enum member instead of an inline code or protocol value.",
|
message:
|
||||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
"Use the owning enum member instead of a template machine code.",
|
||||||
|
selector:
|
||||||
|
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use an enum member instead of constructing a code or protocol value.",
|
message:
|
||||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
"Use an enum member instead of an inline code or protocol value.",
|
||||||
|
selector:
|
||||||
|
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Use an enum member instead of constructing a code or protocol value.",
|
||||||
|
selector:
|
||||||
|
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use a computed enum member as the protocol mapping key.",
|
message: "Use a computed enum member as the protocol mapping key.",
|
||||||
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
selector:
|
||||||
|
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Compare codes with their enum members, not string literals.",
|
message:
|
||||||
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
"Compare codes with their enum members, not string literals.",
|
||||||
|
selector:
|
||||||
|
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"./styles.css": "./dist/index.css"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build && tsc -p tsconfig.json"
|
"build": "vite build && tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
|
|||||||
<Icon />
|
<Icon />
|
||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../bind-icon-animation";
|
||||||
|
import classes from "./OperationIcon.module.css";
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
basket:
|
||||||
|
"M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
|
||||||
|
car:
|
||||||
|
"M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
|
||||||
|
cashWithdrawal: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
|
||||||
|
coffee:
|
||||||
|
"M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
|
||||||
|
deposit:
|
||||||
|
"M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
|
||||||
|
dice:
|
||||||
|
"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
|
||||||
|
download: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
|
||||||
|
education: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
|
||||||
|
grid: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
|
||||||
|
growth: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
|
||||||
|
home: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8",
|
||||||
|
medical: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z",
|
||||||
|
music: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
|
||||||
|
refund: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14",
|
||||||
|
repayment: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
|
||||||
|
server:
|
||||||
|
"M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
|
||||||
|
shield: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
|
||||||
|
shopping: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
|
||||||
|
smoking:
|
||||||
|
"M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
|
||||||
|
subscriptions:
|
||||||
|
"M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
|
||||||
|
taxi:
|
||||||
|
"M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
|
||||||
|
topup:
|
||||||
|
"M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
|
||||||
|
transfer: "M3 7h18l-4-4 M21 17H3l4 4",
|
||||||
|
travel: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
|
||||||
|
wifi:
|
||||||
|
"M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
|
||||||
|
wine:
|
||||||
|
"M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type OperationIconProps = {
|
||||||
|
animation?: string;
|
||||||
|
color: string;
|
||||||
|
variant: keyof typeof paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OperationIcon: FC<OperationIconProps> = (props) => {
|
||||||
|
const { animation, color, variant } = props;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Animation keys select CSS module classes only.
|
||||||
|
const animationClass = animation ? classes[animation] : "";
|
||||||
|
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed keyof the package geometry map.
|
||||||
|
const parts = paths[variant]
|
||||||
|
.split(" M")
|
||||||
|
.map((part, index) => ({ d: index ? `M${part}` : part, key: part }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${animationClass}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height="24"
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
style={{ color: `var(--mantine-color-${color}-7)`, flexShrink: 0 }}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
>
|
||||||
|
{parts.map(part => (
|
||||||
|
<path d={part.d} key={part.key} pathLength={1} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OperationIcon, type OperationIconProps };
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
+6
@@ -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);
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
style={
|
|
||||||
(props.color)
|
|
||||||
? ({
|
|
||||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ const assert = require("node:assert/strict");
|
|||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
const { version } = require("../package.json");
|
const { version } = require("../package.json");
|
||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
|
||||||
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim();
|
const git = (...arguments_) =>
|
||||||
|
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
git("status", "--porcelain"),
|
git("status", "--porcelain"),
|
||||||
"",
|
"",
|
||||||
"Commit all changes before preparing a release",
|
"Commit all changes before preparing a release",
|
||||||
);
|
);
|
||||||
|
|
||||||
const tag_ = git(
|
const tag_ = git(
|
||||||
"tag",
|
"tag",
|
||||||
"--merged",
|
"--merged",
|
||||||
@@ -20,6 +22,7 @@ const tag_ = git(
|
|||||||
)
|
)
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
version,
|
version,
|
||||||
tag_?.slice(1) ?? "0.0.0",
|
tag_?.slice(1) ?? "0.0.0",
|
||||||
|
|||||||
+116
-9
@@ -16,11 +16,13 @@ import storybook from "eslint-plugin-storybook";
|
|||||||
import testingLibrary from "eslint-plugin-testing-library";
|
import testingLibrary from "eslint-plugin-testing-library";
|
||||||
import unicorn from "eslint-plugin-unicorn";
|
import unicorn from "eslint-plugin-unicorn";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { globSync } from "node:fs";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
|
|
||||||
for (const [filePath, code, rule] of [
|
for (const [filePath, code, rule] of [
|
||||||
[
|
[
|
||||||
"src/shared/api/problem-details.ts",
|
"src/shared/api/problem-details.ts",
|
||||||
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
rule,
|
rule,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"src/shared/api/generated/schema.d.ts",
|
"src/shared/api/generated/schema.d.ts",
|
||||||
"packages/ui/dist/index.js",
|
"packages/ui/dist/index.js",
|
||||||
@@ -117,9 +120,11 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
"coverage/index.js",
|
"coverage/index.js",
|
||||||
])
|
])
|
||||||
assert.equal(await eslint.isPathIgnored(file), true, file);
|
assert.equal(await eslint.isPathIgnored(file), true, file);
|
||||||
|
|
||||||
const story = await eslint.calculateConfigForFile(
|
const story = await eslint.calculateConfigForFile(
|
||||||
"src/shared/ui/Button.stories.tsx",
|
"src/shared/ui/Button.stories.tsx",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
||||||
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
||||||
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
||||||
@@ -130,6 +135,7 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
|||||||
const source = "src/shared/api/problem-details.ts";
|
const source = "src/shared/api/problem-details.ts";
|
||||||
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
||||||
|
|
||||||
for (const [preset, filePath] of [
|
for (const [preset, filePath] of [
|
||||||
[tseslint.configs.recommendedTypeChecked, source],
|
[tseslint.configs.recommendedTypeChecked, source],
|
||||||
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
||||||
@@ -147,12 +153,25 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
|||||||
[vitest.configs.recommended, unit],
|
[vitest.configs.recommended, unit],
|
||||||
[testingLibrary.configs["flat/react"], unit],
|
[testingLibrary.configs["flat/react"], unit],
|
||||||
[jestDom.configs["flat/recommended"], unit],
|
[jestDom.configs["flat/recommended"], unit],
|
||||||
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
|
...storybook.configs["flat/recommended"]
|
||||||
|
.filter(config => config.rules)
|
||||||
|
.map(config => [
|
||||||
|
config,
|
||||||
|
config.name.endsWith("main-rules")
|
||||||
|
? ".storybook/main.ts"
|
||||||
|
: "src/shared/ui/Button.stories.tsx",
|
||||||
|
]),
|
||||||
]) {
|
]) {
|
||||||
const configs = [preset].flat();
|
const configs = [preset].flat();
|
||||||
const expected = Object.assign({}, ...configs.map(config => config.rules));
|
|
||||||
|
const expected = Object.assign(
|
||||||
|
{},
|
||||||
|
...configs.map(config => config.rules),
|
||||||
|
);
|
||||||
|
|
||||||
const config = await eslint.calculateConfigForFile(filePath);
|
const config = await eslint.calculateConfigForFile(filePath);
|
||||||
const actual = new Map(Object.entries(config.rules));
|
const actual = new Map(Object.entries(config.rules));
|
||||||
|
|
||||||
for (const [rule, setting] of Object.entries(expected)) {
|
for (const [rule, setting] of Object.entries(expected)) {
|
||||||
const severity = Array.isArray(setting) ? setting[0] : setting;
|
const severity = Array.isArray(setting) ? setting[0] : setting;
|
||||||
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
||||||
@@ -164,10 +183,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
|||||||
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
const [fixed] = await eslint.lintText(
|
||||||
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
||||||
{ filePath },
|
{ filePath },
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(fixed.fatalErrorCount, 0);
|
assert.equal(fixed.fatalErrorCount, 0);
|
||||||
assert.equal(fixed.errorCount, 0);
|
assert.equal(fixed.errorCount, 0);
|
||||||
assert.ok(fixed.output);
|
assert.ok(fixed.output);
|
||||||
@@ -178,10 +199,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async (
|
|||||||
|
|
||||||
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
const [fixed] = await eslint.lintText(
|
||||||
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
||||||
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(fixed.errorCount, 0);
|
assert.equal(fixed.errorCount, 0);
|
||||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
||||||
});
|
});
|
||||||
@@ -190,11 +213,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
|
|||||||
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
const filePath = "src/shared/api/problem-details.ts";
|
const filePath = "src/shared/api/problem-details.ts";
|
||||||
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
|
|
||||||
|
for (const code of [
|
||||||
|
"export function check(code: string) { return code === \"one-off-code\"; }",
|
||||||
|
"export const result = `MOEX_UNAVAILABLE`;",
|
||||||
|
"export const result = { code: \"one-off-code\" };",
|
||||||
|
"export const result = { \"errorCode\": \"one-off-code\" };",
|
||||||
|
"export const result = { code: `dynamic-${Date.now()}` };",
|
||||||
|
"export const result = \"MOEX_UNAVAILABLE\";",
|
||||||
|
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
|
||||||
|
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
|
||||||
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code);
|
assert.ok(
|
||||||
|
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
|
||||||
|
code,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const code of [
|
for (const code of [
|
||||||
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
||||||
"export const select = { code: true, errorCode: true };",
|
"export const select = { code: true, errorCode: true };",
|
||||||
@@ -202,10 +239,80 @@ test("machine codes require enum members without banning ordinary text or Prisma
|
|||||||
]) {
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code);
|
assert.ok(
|
||||||
|
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||||
|
code,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
|
|
||||||
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"));
|
const [fixture] = await eslint.lintText(
|
||||||
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath });
|
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
|
||||||
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison"));
|
{ filePath: "src/shared/api/client.test.ts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [comparison] = await eslint.lintText(
|
||||||
|
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
|
||||||
|
{ filePath },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
comparison.messages.some(
|
||||||
|
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readability enforces syntax without banning VM and schema patterns", async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
|
||||||
|
const model = "src/entities/income/api/useIncome.ts";
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
|
||||||
|
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
|
||||||
|
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
|
||||||
|
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
|
||||||
|
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
|
||||||
|
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
|
||||||
|
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
|
||||||
|
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
|
||||||
|
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
|
||||||
|
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [filePath, code, rule] of invalid) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [filePath, code] of [
|
||||||
|
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
|
||||||
|
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
|
||||||
|
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
|
||||||
|
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
|
||||||
|
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
|
||||||
|
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(globSync("src/**/*.svg"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
|
||||||
|
const eslint = new ESLint({ fix: true });
|
||||||
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
|
||||||
|
const output = fixed.output;
|
||||||
|
assert.ok(output);
|
||||||
|
const [again] = await eslint.lintText(output, { filePath });
|
||||||
|
assert.equal(again.output, undefined);
|
||||||
|
assert.equal(again.errorCount, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
nodePath.join(bin, command),
|
||||||
|
"#!/bin/sh\necho ran >> downstream\n",
|
||||||
|
{
|
||||||
mode: 0o755,
|
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");
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ const accounts = [
|
|||||||
{ id: "a", name: "Основной" },
|
{ id: "a", name: "Основной" },
|
||||||
{ id: "b", name: "ИИС" },
|
{ id: "b", name: "ИИС" },
|
||||||
];
|
];
|
||||||
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
|
||||||
|
const position: Portfolio["positions"][number]
|
||||||
|
= {
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
currentValue: { currency: "RUB", value: "600" },
|
currentValue: { currency: "RUB", value: "600" },
|
||||||
@@ -40,8 +43,9 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
|
|||||||
quantity: "2",
|
quantity: "2",
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
const portfolio: Portfolio = {
|
const portfolio: Portfolio = {
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
isStale: false,
|
isStale: false,
|
||||||
@@ -50,8 +54,11 @@ const portfolio: Portfolio = {
|
|||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
||||||
};
|
};
|
||||||
const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
|
||||||
|
const receipt: BrokerageOperation
|
||||||
|
= {
|
||||||
accruedInterest: null,
|
accruedInterest: null,
|
||||||
category: "income",
|
category: "income",
|
||||||
commission: null,
|
commission: null,
|
||||||
@@ -67,11 +74,15 @@ const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixtur
|
|||||||
quantity: null,
|
quantity: null,
|
||||||
ticker: "BOND",
|
ticker: "BOND",
|
||||||
type: "OPERATION_TYPE_COUPON",
|
type: "OPERATION_TYPE_COUPON",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
const items = accounts.map(account => ({ ...receipt, account }));
|
const items = accounts.map(account => ({ ...receipt, account }));
|
||||||
const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
|
||||||
|
const analytics: IncomeAnalytics
|
||||||
|
= {
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
bucketGranularity: "day",
|
bucketGranularity: "day",
|
||||||
buckets: [],
|
buckets: [],
|
||||||
@@ -105,17 +116,21 @@ const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
yearComparison: null,
|
yearComparison: null,
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
const CurrentLocation: FC = () => {
|
const CurrentLocation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<output aria-label="Текущий путь">
|
<output aria-label="Текущий путь">
|
||||||
{location.pathname}
|
{location.pathname}
|
||||||
|
|
||||||
{location.search}
|
{location.search}
|
||||||
</output>
|
</output>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPage(path: string) {
|
function renderPage(path: string) {
|
||||||
const client = createTestQueryClient();
|
const client = createTestQueryClient();
|
||||||
render(
|
render(
|
||||||
@@ -124,12 +139,14 @@ function renderPage(path: string) {
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
|
||||||
<CurrentLocation />
|
<CurrentLocation />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +161,17 @@ beforeEach(() => {
|
|||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource(
|
resource(
|
||||||
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
accounts.map(
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
account =>
|
||||||
|
({
|
||||||
...account,
|
...account,
|
||||||
openedOn: null,
|
openedOn: null,
|
||||||
portfolioSummary: null,
|
portfolioSummary: null,
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
}
|
}),
|
||||||
/* eslint-enable unicorn/no-null */))),
|
/* eslint-enable unicorn/no-null */
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -162,15 +182,17 @@ beforeEach(() => {
|
|||||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
({ params }) =>
|
({ params }) =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
resource(
|
||||||
{
|
{
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
items,
|
items,
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
/* eslint-enable unicorn/no-null */
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||||
@@ -181,8 +203,10 @@ beforeEach(() => {
|
|||||||
resource({
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
calculatedAt: receipt.occurredAt,
|
calculatedAt: receipt.occurredAt,
|
||||||
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
events: accounts.map(
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
account =>
|
||||||
|
({
|
||||||
account,
|
account,
|
||||||
date: "2026-09-15",
|
date: "2026-09-15",
|
||||||
dateKind: "event",
|
dateKind: "event",
|
||||||
@@ -194,8 +218,9 @@ beforeEach(() => {
|
|||||||
projectedAmount: { currency: "RUB", value: "2" },
|
projectedAmount: { currency: "RUB", value: "2" },
|
||||||
ticker: "BOND",
|
ticker: "BOND",
|
||||||
type: "coupon",
|
type: "coupon",
|
||||||
}
|
}),
|
||||||
/* eslint-enable unicorn/no-null */))),
|
/* eslint-enable unicorn/no-null */
|
||||||
|
),
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotalsByCurrency: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -235,7 +260,11 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
for (const suffix of ["operations", "income", "events"])
|
for (const suffix of ["operations", "income", "events"])
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`),
|
screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.find(
|
||||||
|
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,8 +276,10 @@ describe("All accounts routes", () => {
|
|||||||
({ params, request }) => {
|
({ params, request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
|
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
resource(
|
||||||
{
|
{
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: !url.searchParams.has("cursor"),
|
hasNext: !url.searchParams.has("cursor"),
|
||||||
@@ -262,8 +293,9 @@ describe("All accounts routes", () => {
|
|||||||
],
|
],
|
||||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
/* eslint-enable unicorn/no-null */
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -281,9 +313,11 @@ describe("All accounts routes", () => {
|
|||||||
requests.some(url => url.pathname.includes("/b/operations")),
|
requests.some(url => url.pathname.includes("/b/operations")),
|
||||||
).toBe(true),
|
).toBe(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selected = requests.find(url =>
|
const selected = requests.find(url =>
|
||||||
url.pathname.includes("/b/operations"),
|
url.pathname.includes("/b/operations"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
||||||
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
||||||
expect(selected?.searchParams.get("categories")).toBe("income");
|
expect(selected?.searchParams.get("categories")).toBe("income");
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe("AppRoutes", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/login", async ({ request }) => {
|
http.post("*/api/v1/auth/login", async ({ request }) => {
|
||||||
credentials = await request.json();
|
credentials = await request.json();
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
accessToken: "login-token",
|
accessToken: "login-token",
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
user: { email: "user@example.com", id: "user-1" },
|
||||||
@@ -103,7 +104,8 @@ describe("AppRoutes", () => {
|
|||||||
),
|
),
|
||||||
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
||||||
http.get("*/api/v1/bank-cards/summary", () =>
|
http.get("*/api/v1/bank-cards/summary", () =>
|
||||||
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
HttpResponse.json(
|
||||||
{
|
{
|
||||||
cardId: null,
|
cardId: null,
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -111,8 +113,9 @@ describe("AppRoutes", () => {
|
|||||||
month: "2026-08",
|
month: "2026-08",
|
||||||
net: { currency: "RUB", value: "0" },
|
net: { currency: "RUB", value: "0" },
|
||||||
refunds: { currency: "RUB", value: "0" },
|
refunds: { currency: "RUB", value: "0" },
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
/* eslint-enable unicorn/no-null */
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderRoutes("/cards?month=2026-08");
|
renderRoutes("/cards?month=2026-08");
|
||||||
|
|||||||
@@ -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 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,29 +24,36 @@ type AccountDocumentTitleProps = {
|
|||||||
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
|
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useBrokerageAccounts();
|
const { data } = useBrokerageAccounts();
|
||||||
|
|
||||||
const accountName
|
const accountName
|
||||||
= props.accountId === "all"
|
= props.accountId === "all"
|
||||||
? t("accounts.all")
|
? t("accounts.all")
|
||||||
: (data?.find(account => account.id === props.accountId)?.name
|
: (data?.find(account => account.id === props.accountId)?.name
|
||||||
?? t("account.title"));
|
?? t("account.title"));
|
||||||
|
|
||||||
let section = t("portfolio.title");
|
let section = t("portfolio.title");
|
||||||
|
|
||||||
switch (props.section ?? "") {
|
switch (props.section ?? "") {
|
||||||
case "income": {
|
case "income": {
|
||||||
section = t("income.title");
|
section = t("income.title");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "operations": {
|
case "operations": {
|
||||||
section = t("ledger.title");
|
section = t("ledger.title");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "events": {
|
case "events": {
|
||||||
section = t("events.title");
|
section = t("events.title");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DocumentTitle title={`${section} — ${accountName}`} />;
|
return <DocumentTitle title={`${section} — ${accountName}`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,18 +62,21 @@ type InstrumentDocumentTitleProps = { identifier: string };
|
|||||||
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useBond(props.identifier);
|
const { data } = useBond(props.identifier);
|
||||||
|
|
||||||
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
|
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useStock(props.identifier);
|
const { data } = useStock(props.identifier);
|
||||||
|
|
||||||
return <DocumentTitle title={data?.name ?? t("stock.share")} />;
|
return <DocumentTitle title={data?.name ?? t("stock.share")} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useBankCard(props.identifier);
|
const { data } = useBankCard(props.identifier);
|
||||||
|
|
||||||
return <DocumentTitle title={data?.name ?? t("bank.card")} />;
|
return <DocumentTitle title={data?.name ?? t("bank.card")} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +104,14 @@ const ProtectedDocumentTitle: FC = () => {
|
|||||||
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
|
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
|
||||||
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
|
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
|
||||||
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
|
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
|
||||||
return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
|
|
||||||
|
return settings
|
||||||
|
? (
|
||||||
|
<DocumentTitle title={t("navigation.settings")} />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<DocumentTitle title={t("navigation.accounts")} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ProtectedDocumentTitle };
|
export { ProtectedDocumentTitle };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProtectedDocumentTitle />
|
<ProtectedDocumentTitle />
|
||||||
|
|
||||||
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
|
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
|||||||
|
|
||||||
const session = { isAuthenticated: false };
|
const session = { isAuthenticated: false };
|
||||||
vi.mock("@/features/auth", () => ({
|
vi.mock("@/features/auth", () => ({
|
||||||
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{ user: session.isAuthenticated ? {} : null }
|
useAuth: () =>
|
||||||
/* eslint-enable unicorn/no-null */)),
|
({ user: session.isAuthenticated ? {} : null }),
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ThrowingRoute: FC = () => {
|
const ThrowingRoute: FC = () => {
|
||||||
@@ -39,6 +40,7 @@ const RouteHarness: FC = () => {
|
|||||||
<button onClick={() => navigate("/healthy")} type="button">
|
<button onClick={() => navigate("/healthy")} type="button">
|
||||||
go healthy
|
go healthy
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
<HealthyRoute />
|
<HealthyRoute />
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
@@ -62,10 +64,13 @@ describe("RouteErrorBoundary", () => {
|
|||||||
async (path) => {
|
async (path) => {
|
||||||
session.isAuthenticated = path === "/accounts";
|
session.isAuthenticated = path === "/accounts";
|
||||||
let isFailing = true;
|
let isFailing = true;
|
||||||
|
|
||||||
const RecoverableRoute: FC = () => {
|
const RecoverableRoute: FC = () => {
|
||||||
if (isFailing) throw new Error("temporary failure");
|
if (isFailing) throw new Error("temporary failure");
|
||||||
|
|
||||||
return <div>recovered route</div>;
|
return <div>recovered route</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
@@ -116,10 +121,12 @@ describe("RouteErrorBoundary", () => {
|
|||||||
expect(reload).toHaveAttribute("href", "/login");
|
expect(reload).toHaveAttribute("href", "/login");
|
||||||
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
|
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
|
||||||
let isIntercepted = true;
|
let isIntercepted = true;
|
||||||
|
|
||||||
const preventNavigation = (event: MouseEvent) => {
|
const preventNavigation = (event: MouseEvent) => {
|
||||||
isIntercepted = event.defaultPrevented;
|
isIntercepted = event.defaultPrevented;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("click", preventNavigation, { once: true });
|
document.addEventListener("click", preventNavigation, { once: true });
|
||||||
fireEvent.click(reload);
|
fireEvent.click(reload);
|
||||||
expect(isIntercepted).toBe(false);
|
expect(isIntercepted).toBe(false);
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children;
|
return this.state.hasError
|
||||||
|
? (
|
||||||
|
<RouteErrorFallback authenticated={this.props.authenticated} />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
this.props.children
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +52,13 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("routeError.title")}</Title>
|
<Title order={1}>{t("routeError.title")}</Title>
|
||||||
|
|
||||||
<Text>{t("routeError.text")}</Text>
|
<Text>{t("routeError.text")}</Text>
|
||||||
|
|
||||||
<Button component={Link} to={to}>
|
<Button component={Link} to={to}>
|
||||||
{t("routeError.action")}
|
{t("routeError.action")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button component="a" href={to} variant="default">
|
<Button component="a" href={to} variant="default">
|
||||||
{t("routeError.reload")}
|
{t("routeError.reload")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { RouteTransition } from "./RouteTransition";
|
import { RouteTransition } from "./RouteTransition";
|
||||||
|
|
||||||
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
|
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
|
||||||
type RouteSkeletonVariant = "auth" | "content" | "shell";
|
|
||||||
|
type RouteSkeletonVariant = "auth"
|
||||||
|
| "content"
|
||||||
|
| "shell";
|
||||||
|
|
||||||
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||||
const { variant } = props;
|
const { variant } = props;
|
||||||
@@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<Skeleton height={32} width="45%" />
|
<Skeleton height={32} width="45%" />
|
||||||
|
|
||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
|
|
||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
|
|
||||||
<Skeleton height={36} radius="sm" />
|
<Skeleton height={36} radius="sm" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" p="md">
|
<Group justify="space-between" p="md">
|
||||||
<Skeleton height={28} width={160} />
|
<Skeleton height={28} width={160} />
|
||||||
|
|
||||||
<Skeleton height={28} width={96} />
|
<Skeleton height={28} width={96} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Container fluid p="md">
|
<Container fluid p="md">
|
||||||
<Skeleton height={20} width="30%" />
|
<Skeleton height={20} width="30%" />
|
||||||
|
|
||||||
<Stack mt="lg">
|
<Stack mt="lg">
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
|
|
||||||
<Skeleton height={160} radius="md" />
|
<Skeleton height={160} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
|
|
||||||
<Skeleton height={160} radius="md" />
|
<Skeleton height={160} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -61,6 +72,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
|
|
||||||
const LazyPage: FC<LazyPageProps> = (props) => {
|
const LazyPage: FC<LazyPageProps> = (props) => {
|
||||||
const { children, variant } = props;
|
const { children, variant } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
||||||
{variant === "shell"
|
{variant === "shell"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const Protected: FC<PropsWithChildren> = (props) => {
|
|||||||
const { ready, user } = useAuth();
|
const { ready, user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
|
|
||||||
return user
|
return user
|
||||||
? (
|
? (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
@@ -30,13 +31,17 @@ const Public: FC<PropsWithChildren> = (props) => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
const requested = (location.state as null | { returnTo?: string })?.returnTo;
|
|
||||||
|
const requested = (location.state as null
|
||||||
|
| { returnTo?: string })?.returnTo;
|
||||||
|
|
||||||
const returnTo
|
const returnTo
|
||||||
= typeof requested === "string"
|
= typeof requested === "string"
|
||||||
&& requested.startsWith("/")
|
&& requested.startsWith("/")
|
||||||
&& !requested.startsWith("//")
|
&& !requested.startsWith("//")
|
||||||
? requested
|
? requested
|
||||||
: "/accounts";
|
: "/accounts";
|
||||||
|
|
||||||
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
|
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
+13
-19
@@ -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
|
const onChange: (value: null | string) => void = (value) => {
|
||||||
allowDeselect={false}
|
|
||||||
data={data}
|
|
||||||
label={t("accounts.scope")}
|
|
||||||
maw={360}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!value || value === accountId) return;
|
if (!value || value === accountId) return;
|
||||||
const filters = new URLSearchParams(search);
|
const filters = new URLSearchParams(search);
|
||||||
filters.delete("cursor");
|
filters.delete("cursor");
|
||||||
void navigate({
|
void navigate({
|
||||||
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
|
pathname: ["/accounts", encodeURIComponent(value), section]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/"),
|
||||||
search: filters.toString(),
|
search: filters.toString(),
|
||||||
});
|
});
|
||||||
}}
|
};
|
||||||
value={accountId}
|
|
||||||
w="100%"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { AccountScopeSwitch };
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMatch } from "react-router";
|
import { useMatch } from "react-router";
|
||||||
|
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
import { AccountScopeSwitch } from "./AccountScopeSwitch";
|
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
||||||
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const operations = useMatch({
|
const operations = useMatch({
|
||||||
end: true,
|
end: true,
|
||||||
path: "/accounts/:accountId/operations",
|
path: "/accounts/:accountId/operations",
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
||||||
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
||||||
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
||||||
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={operations.params.accountId ?? ""}
|
accountId={operations.params.accountId ?? ""}
|
||||||
section="operations"
|
section="operations"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={operations.params.accountId ?? ""}
|
||||||
section="operations"
|
section="operations"
|
||||||
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={events.params.accountId ?? ""}
|
accountId={events.params.accountId ?? ""}
|
||||||
section="events"
|
section="events"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={events.params.accountId ?? ""}
|
accountId={events.params.accountId ?? ""}
|
||||||
section="events"
|
section="events"
|
||||||
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={income.params.accountId ?? ""}
|
accountId={income.params.accountId ?? ""}
|
||||||
section="income"
|
section="income"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={income.params.accountId ?? ""}
|
accountId={income.params.accountId ?? ""}
|
||||||
section="income"
|
section="income"
|
||||||
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
||||||
|
|
||||||
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -80,7 +86,12 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
|
|
||||||
|
return settings
|
||||||
|
? (
|
||||||
|
<BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AppBreadcrumbs };
|
export { AppBreadcrumbs };
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type BondBreadcrumbsProps = { identifier: string };
|
type BondBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useBond(props.identifier);
|
const { data } = useBond(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { Link } from "react-router";
|
|||||||
import classes from "./AppBreadcrumbs.module.css";
|
import classes from "./AppBreadcrumbs.module.css";
|
||||||
|
|
||||||
type BreadcrumbTrailProps = { items: Crumb[] };
|
type BreadcrumbTrailProps = { items: Crumb[] };
|
||||||
|
|
||||||
type Crumb = { label: string; to?: string };
|
type Crumb = { label: string; to?: string };
|
||||||
|
|
||||||
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
||||||
const { items } = props;
|
const { items } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
aria-label={t("breadcrumbs.label")}
|
aria-label={t("breadcrumbs.label")}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useBankCard } from "@/entities/bank-card";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type CardBreadcrumbsProps = { identifier: string };
|
type CardBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useBankCard(props.identifier);
|
const { data } = useBankCard(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useStock } from "@/entities/stock";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type StockBreadcrumbsProps = { identifier: string };
|
type StockBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useStock(props.identifier);
|
const { data } = useStock(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -116,46 +76,39 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
leftSection={(
|
leftSection={(
|
||||||
<span
|
<span
|
||||||
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={20}
|
|
||||||
ref={bindIconAnimation}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={20}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{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) => {
|
||||||
|
const appearanceClass = pathname.startsWith(item.to)
|
||||||
|
? classes.active
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
<RouterNavLink
|
<RouterNavLink
|
||||||
className={
|
className={appearanceClass}
|
||||||
(pathname.startsWith(item.to)) ? (classes.active) : undefined
|
|
||||||
}
|
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</RouterNavLink>
|
</RouterNavLink>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</MantineAppShell>
|
</MantineAppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "./queries";
|
} from "./queries";
|
||||||
|
|
||||||
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
|
||||||
|
const card: BankCard
|
||||||
|
= {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
balance: { currency: "RUB", value: "100" },
|
balance: { currency: "RUB", value: "100" },
|
||||||
bankName: null,
|
bankName: null,
|
||||||
@@ -31,8 +33,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
|
|||||||
openingDate: "2026-09-01",
|
openingDate: "2026-09-01",
|
||||||
paymentSystem: null,
|
paymentSystem: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
const transfer: CreateBankOperation = {
|
const transfer: CreateBankOperation = {
|
||||||
amount: { currency: "RUB", value: "90071992547409.93" },
|
amount: { currency: "RUB", value: "90071992547409.93" },
|
||||||
cardId: "a",
|
cardId: "a",
|
||||||
@@ -55,6 +58,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
|||||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||||
const key = new URL(request.url).pathname + new URL(request.url).search;
|
const key = new URL(request.url).pathname + new URL(request.url).search;
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
|
||||||
return HttpResponse.json([card]);
|
return HttpResponse.json([card]);
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
||||||
@@ -84,7 +88,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
|||||||
page: 1,
|
page: 1,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
|
|
||||||
|
return url.pathname.includes("/operations/")
|
||||||
|
? HttpResponse.json({ ...transfer, version: 1 })
|
||||||
|
: HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -94,15 +101,18 @@ function useReadModels() {
|
|||||||
const a = useBankCard("a");
|
const a = useBankCard("a");
|
||||||
const b = useBankCard("b");
|
const b = useBankCard("b");
|
||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
|
|
||||||
const history = useBankOperations("a", {
|
const history = useBankOperations("a", {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
month: "2026-09",
|
month: "2026-09",
|
||||||
page: 2,
|
page: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenses = useBankExpenses("a", { page: 1 });
|
const expenses = useBankExpenses("a", { page: 1 });
|
||||||
const september = useBankSummary("2026-09");
|
const september = useBankSummary("2026-09");
|
||||||
const august = useBankSummary("2026-08", "b");
|
const august = useBankSummary("2026-08", "b");
|
||||||
const original = useBankOperation("original");
|
const original = useBankOperation("original");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutations: useBankMutations(),
|
mutations: useBankMutations(),
|
||||||
reads: [
|
reads: [
|
||||||
@@ -134,6 +144,7 @@ describe("banking API", () => {
|
|||||||
expect(url.searchParams.get("month")).toBe("2026-09");
|
expect(url.searchParams.get("month")).toBe("2026-09");
|
||||||
if (isFail && page === 2)
|
if (isFail && page === 2)
|
||||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
hasNext: page === 1,
|
hasNext: page === 1,
|
||||||
items: [{ ...transfer, id: String(page) }],
|
items: [{ ...transfer, id: String(page) }],
|
||||||
@@ -142,10 +153,12 @@ describe("banking API", () => {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
expect(pages).toEqual([1, 2]);
|
expect(pages).toEqual([1, 2]);
|
||||||
expect(result.current.data?.items.map(item => item.id)).toEqual([
|
expect(result.current.data?.items.map(item => item.id)).toEqual([
|
||||||
@@ -153,10 +166,12 @@ describe("banking API", () => {
|
|||||||
"2",
|
"2",
|
||||||
]);
|
]);
|
||||||
isFail = true;
|
isFail = true;
|
||||||
|
|
||||||
const { result: failedResult } = renderHook(
|
const { result: failedResult } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
||||||
expect(failedResult.current.data).toBeUndefined();
|
expect(failedResult.current.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -164,15 +179,15 @@ describe("banking API", () => {
|
|||||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
server.use(...readHandlers(counts));
|
server.use(...readHandlers(counts));
|
||||||
|
|
||||||
const { result } = renderHook(useReadModels, {
|
const { result } = renderHook(useReadModels, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||||
);
|
);
|
||||||
expect(counts.keys()).toContain(
|
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
|
||||||
"/api/v1/bank-cards?includeArchived=true",
|
|
||||||
);
|
|
||||||
expect(counts.keys()).toContain(
|
expect(counts.keys()).toContain(
|
||||||
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
||||||
);
|
);
|
||||||
@@ -184,10 +199,13 @@ describe("banking API", () => {
|
|||||||
|
|
||||||
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
||||||
let token = "expired";
|
let token = "expired";
|
||||||
|
|
||||||
const refresh = vi.fn(() => {
|
const refresh = vi.fn(() => {
|
||||||
token = "renewed";
|
token = "renewed";
|
||||||
|
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
||||||
const bodies: unknown[] = [];
|
const bodies: unknown[] = [];
|
||||||
const headers: (null | string)[] = [];
|
const headers: (null | string)[] = [];
|
||||||
@@ -195,14 +213,17 @@ describe("banking API", () => {
|
|||||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||||
bodies.push(await request.json());
|
bodies.push(await request.json());
|
||||||
headers.push(request.headers.get("Authorization"));
|
headers.push(request.headers.get("Authorization"));
|
||||||
|
|
||||||
return bodies.length === 1
|
return bodies.length === 1
|
||||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||||
: HttpResponse.json({ ...transfer, version: 1 });
|
: HttpResponse.json({ ...transfer, version: 1 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useBankMutations, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||||
expect(bodies).toEqual([transfer, transfer]);
|
expect(bodies).toEqual([transfer, transfer]);
|
||||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||||
@@ -214,14 +235,17 @@ describe("banking API", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||||
bodies.push(await request.json());
|
bodies.push(await request.json());
|
||||||
|
|
||||||
return bodies.length === 1
|
return bodies.length === 1
|
||||||
? HttpResponse.error()
|
? HttpResponse.error()
|
||||||
: HttpResponse.json({ ...transfer, version: 1 });
|
: HttpResponse.json({ ...transfer, version: 1 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useBankMutations, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
result.current.createOperation.mutateAsync(transfer),
|
result.current.createOperation.mutateAsync(transfer),
|
||||||
@@ -239,13 +263,16 @@ describe("banking API", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/bank-cards/operations", async () => {
|
http.post("*/api/v1/bank-cards/operations", async () => {
|
||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||||
let saving: Promise<unknown>;
|
let saving: Promise<unknown>;
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -310,10 +337,12 @@ describe("banking API", () => {
|
|||||||
HttpResponse.json({ code }, { status: 409 }),
|
HttpResponse.json({ code }, { status: 409 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -337,6 +366,7 @@ describe("banking API", () => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
requests.push(body);
|
requests.push(body);
|
||||||
name = "Groceries";
|
name = "Groceries";
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
id: "food",
|
id: "food",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@@ -348,14 +378,17 @@ describe("banking API", () => {
|
|||||||
"*/api/v1/bank-cards/operations/original",
|
"*/api/v1/bank-cards/operations/original",
|
||||||
async ({ request }) => {
|
async ({ request }) => {
|
||||||
requests.push(await request.json());
|
requests.push(await request.json());
|
||||||
|
|
||||||
return HttpResponse.json({ ...transfer, version: 2 });
|
return HttpResponse.json({ ...transfer, version: 2 });
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
||||||
requests.push(new URL(request.url).searchParams.get("version"));
|
requests.push(new URL(request.url).searchParams.get("version"));
|
||||||
|
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({
|
() => ({
|
||||||
categories: useBankCategories(),
|
categories: useBankCategories(),
|
||||||
@@ -363,6 +396,7 @@ describe("banking API", () => {
|
|||||||
}),
|
}),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||||
);
|
);
|
||||||
@@ -406,9 +440,11 @@ describe("banking API", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { queryClient, wrapper } = createQueryWrapper();
|
const { queryClient, wrapper } = createQueryWrapper();
|
||||||
|
|
||||||
const { rerender, result } = renderHook(() => useBankCard("a"), {
|
const { rerender, result } = renderHook(() => useBankCard("a"), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
||||||
await apiClient.PUT("/api/v1/tbank-connection", {
|
await apiClient.PUT("/api/v1/tbank-connection", {
|
||||||
body: { token: "synthetic" },
|
body: { token: "synthetic" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,21 +10,16 @@ export {
|
|||||||
useBankOperations,
|
useBankOperations,
|
||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "./api/queries";
|
} from "./api/queries";
|
||||||
export {
|
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
|
||||||
bankDateInputValue,
|
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
|
||||||
bankDateSchema,
|
|
||||||
bankMoneyValue,
|
export { bankOperationAppearance } from "./model/operation-appearance";
|
||||||
bankToday,
|
|
||||||
moneyCents,
|
|
||||||
positiveMoneySchema,
|
|
||||||
} from "./model/form-validation";
|
|
||||||
export { bankOperationKinds } from "./model/operation-kinds";
|
export { bankOperationKinds } from "./model/operation-kinds";
|
||||||
export { bankPeriod } from "./model/period";
|
export { bankPeriod } from "./model/period";
|
||||||
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
||||||
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
||||||
export { BankFeedback } from "./ui/BankFeedback";
|
export { BankFeedback } from "./ui/BankFeedback";
|
||||||
export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon";
|
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
|
||||||
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
|
|
||||||
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
||||||
export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
|
export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
|
||||||
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
|
||||||
|
import type { ExpenseCategory } from "../api/queries";
|
||||||
|
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||||
|
return z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(
|
||||||
|
value => value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
||||||
|
{ error: validationMessage("bank.validation.category") },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
value =>
|
||||||
|
!categories?.some(
|
||||||
|
c =>
|
||||||
|
c.id !== editingId
|
||||||
|
&& c.name.toLocaleLowerCase("ru-RU")
|
||||||
|
=== value.toLocaleLowerCase("ru-RU"),
|
||||||
|
),
|
||||||
|
{ error: validationMessage("bank.validation.duplicate") },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { categorySchema };
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
|
||||||
bankCardSchema,
|
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
|
||||||
bankDateInputValue,
|
|
||||||
bankDateSchema,
|
|
||||||
bankMoneyValue,
|
|
||||||
bankMonth,
|
|
||||||
bankToday,
|
|
||||||
positiveMoneySchema,
|
|
||||||
} from "./form-validation";
|
|
||||||
|
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
describe("bank form input", () => {
|
describe("bank form input", () => {
|
||||||
@@ -16,6 +9,7 @@ describe("bank form input", () => {
|
|||||||
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
|
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const invalid of [
|
for (const invalid of [
|
||||||
"92233720368547758.08",
|
"92233720368547758.08",
|
||||||
"1.001",
|
"1.001",
|
||||||
@@ -26,7 +20,9 @@ describe("bank form input", () => {
|
|||||||
]) {
|
]) {
|
||||||
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
|
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
|
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
bankName: "",
|
bankName: "",
|
||||||
lastFour: "",
|
lastFour: "",
|
||||||
@@ -34,6 +30,7 @@ describe("bank form input", () => {
|
|||||||
openingBalance: "-92233720368547758.08",
|
openingBalance: "-92233720368547758.08",
|
||||||
openingDate: "2026-01-01",
|
openingDate: "2026-01-01",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(bankCardSchema.safeParse(values).success).toBe(true);
|
expect(bankCardSchema.safeParse(values).success).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
bankCardSchema.safeParse({
|
bankCardSchema.safeParse({
|
||||||
@@ -62,6 +59,7 @@ it("converts local date input without accepting impossible or unfinished dates",
|
|||||||
expect(
|
expect(
|
||||||
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
|
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
for (const value of ["31.02.2026", "07.08.", ""]) {
|
for (const value of ["31.02.2026", "07.08.", ""]) {
|
||||||
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
|
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
|
||||||
|
|
||||||
const DECIMAL_PART_COUNT = 2;
|
const DECIMAL_PART_COUNT = 2;
|
||||||
const CENTS_PRECISION = 2;
|
const CENTS_PRECISION = 2;
|
||||||
const ISO_DATE_LENGTH = 10;
|
|
||||||
|
|
||||||
const ISO_MONTH_LENGTH = 7;
|
const ISO_MONTH_LENGTH = 7;
|
||||||
const MAX_CARD_NAME_LENGTH = 100;
|
|
||||||
|
|
||||||
function bankDateInputValue(value: string) {
|
function bankDateInputValue(value: string) {
|
||||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function bankMoneyValue(value: string) {
|
function bankMoneyValue(value: string) {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
.replace(",", ".")
|
.replace(",", ".")
|
||||||
.replace(/^(-?)0+(?=\d)/, "$1");
|
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function bankMonth(value: null | string) {
|
function bankMonth(value: null | string) {
|
||||||
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
||||||
? value
|
? value
|
||||||
: bankToday().slice(0, ISO_MONTH_LENGTH);
|
: bankToday().slice(0, ISO_MONTH_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bankToday() {
|
function bankToday() {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -31,84 +28,21 @@ function bankToday() {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyCents(value: string): bigint | null {
|
function moneyCents(value: string): bigint | null {
|
||||||
const normalized = value.trim().replace(",", ".");
|
const normalized = value.trim().replace(",", ".");
|
||||||
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null;
|
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
||||||
const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT);
|
// eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const [whole, fraction = ""] = normalized
|
||||||
|
.replace("-", "")
|
||||||
|
.split(".", DECIMAL_PART_COUNT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
||||||
* (normalized.startsWith("-") ? -1n : 1n)
|
* (normalized.startsWith("-") ? -1n : 1n)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
|
||||||
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
|
||||||
|
|
||||||
const moneySchema = z.string().refine(
|
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
|
||||||
(value) => {
|
|
||||||
const cents = moneyCents(value);
|
|
||||||
return (
|
|
||||||
cents !== null
|
|
||||||
&& cents >= MIN_BALANCE_KOPECKS
|
|
||||||
&& cents <= MAX_BALANCE_KOPECKS
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ error: validationMessage("bank.validation.money") },
|
|
||||||
);
|
|
||||||
const positiveMoneySchema = moneySchema.refine(
|
|
||||||
(value) => {
|
|
||||||
const cents = moneyCents(value);
|
|
||||||
return cents === null || cents > 0n;
|
|
||||||
},
|
|
||||||
{ error: validationMessage("bank.validation.positive") },
|
|
||||||
);
|
|
||||||
const bankDateSchema = z
|
|
||||||
.string()
|
|
||||||
.refine(
|
|
||||||
value =>
|
|
||||||
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
||||||
&& value >= "0001-01-01"
|
|
||||||
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
|
|
||||||
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) === value
|
|
||||||
&& value <= bankToday(),
|
|
||||||
{ error: validationMessage("bank.validation.date") },
|
|
||||||
);
|
|
||||||
const bankCardSchema = z.object({
|
|
||||||
bankName: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.max(
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
validationMessage("bank.validation.bankName")(undefined),
|
|
||||||
),
|
|
||||||
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
|
|
||||||
error: validationMessage("bank.validation.lastFour"),
|
|
||||||
}),
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, validationMessage("bank.validation.name")(undefined))
|
|
||||||
.max(
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
validationMessage("bank.validation.name")(undefined),
|
|
||||||
),
|
|
||||||
openingBalance: moneySchema,
|
|
||||||
openingDate: bankDateSchema,
|
|
||||||
paymentSystem: z
|
|
||||||
.enum(["", "mir", "visa", "mastercard"], {
|
|
||||||
error: validationMessage("bank.validation.paymentSystem"),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
type BankCardValues = z.infer<typeof bankCardSchema>;
|
|
||||||
|
|
||||||
export {
|
|
||||||
bankCardSchema,
|
|
||||||
type BankCardValues,
|
|
||||||
bankDateInputValue,
|
|
||||||
bankDateSchema,
|
|
||||||
bankMoneyValue,
|
|
||||||
bankMonth,
|
|
||||||
bankToday,
|
|
||||||
moneyCents,
|
|
||||||
positiveMoneySchema,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
|
||||||
|
import { bankToday, moneyCents } from "./form-validation";
|
||||||
|
const ISO_DATE_LENGTH = 10;
|
||||||
|
const MAX_CARD_NAME_LENGTH = 100;
|
||||||
|
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
|
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
|
|
||||||
|
const moneySchema = z.string().refine(
|
||||||
|
(value) => {
|
||||||
|
const cents = moneyCents(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
cents !== null
|
||||||
|
&& cents >= MIN_BALANCE_KOPECKS
|
||||||
|
&& cents <= MAX_BALANCE_KOPECKS
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ error: validationMessage("bank.validation.money") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const positiveMoneySchema = moneySchema.refine(
|
||||||
|
(value) => {
|
||||||
|
const cents = moneyCents(value);
|
||||||
|
|
||||||
|
return cents === null || cents > 0n;
|
||||||
|
},
|
||||||
|
{ error: validationMessage("bank.validation.positive") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const bankDateSchema = z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
value =>
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||||
|
&& value >= "0001-01-01"
|
||||||
|
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
|
||||||
|
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH)
|
||||||
|
=== value
|
||||||
|
&& value <= bankToday(),
|
||||||
|
{ error: validationMessage("bank.validation.date") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const bankCardSchema = z.object({
|
||||||
|
bankName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
validationMessage("bank.validation.bankName")(undefined),
|
||||||
|
),
|
||||||
|
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
|
||||||
|
error: validationMessage("bank.validation.lastFour"),
|
||||||
|
}),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, validationMessage("bank.validation.name")(undefined))
|
||||||
|
.max(
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
validationMessage("bank.validation.name")(undefined),
|
||||||
|
),
|
||||||
|
openingBalance: moneySchema,
|
||||||
|
openingDate: bankDateSchema,
|
||||||
|
paymentSystem: z
|
||||||
|
.enum(["", "mir", "visa", "mastercard"], {
|
||||||
|
error: validationMessage("bank.validation.paymentSystem"),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BankCardValues = z.infer<typeof bankCardSchema>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
bankCardSchema,
|
||||||
|
type BankCardValues,
|
||||||
|
bankDateSchema,
|
||||||
|
positiveMoneySchema,
|
||||||
|
};
|
||||||
@@ -1,124 +1,132 @@
|
|||||||
|
import type { OperationIconProps } from "@wealthledger/icons";
|
||||||
|
|
||||||
import type { BankOperation } from "../api/queries";
|
import type { BankOperation } from "../api/queries";
|
||||||
|
|
||||||
type OperationIcon = { animation?: string; color: string; path: string };
|
type OperationIcon = {
|
||||||
|
animation?: string;
|
||||||
|
color: string;
|
||||||
|
variant: OperationIconProps["variant"];
|
||||||
|
};
|
||||||
|
|
||||||
const categoryIcons: Record<string, OperationIcon> = {
|
const categoryIcons: Record<string, OperationIcon> = {
|
||||||
"алкоголь": {
|
"алкоголь": {
|
||||||
animation: "wine",
|
animation: "wine",
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
|
variant: "wine",
|
||||||
},
|
},
|
||||||
"вклады": {
|
"вклады": {
|
||||||
animation: "deposit",
|
animation: "deposit",
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
|
variant: "deposit",
|
||||||
},
|
},
|
||||||
"жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" },
|
"жилье": { color: "orange", variant: "home" },
|
||||||
"здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" },
|
"здоровье": { color: "red", variant: "medical" },
|
||||||
"инвестиции и накопления": {
|
"инвестиции и накопления": {
|
||||||
animation: "growth",
|
animation: "growth",
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
|
variant: "growth",
|
||||||
},
|
},
|
||||||
"кафе": {
|
"кафе": {
|
||||||
animation: "coffee",
|
animation: "coffee",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
|
variant: "coffee",
|
||||||
},
|
},
|
||||||
"образование": {
|
"образование": {
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
|
variant: "education",
|
||||||
},
|
},
|
||||||
"переводы": {
|
"переводы": {
|
||||||
animation: "transfer",
|
animation: "transfer",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M3 7h18l-4-4 M21 17H3l4 4",
|
variant: "transfer",
|
||||||
},
|
},
|
||||||
"погашение кредита": {
|
"погашение кредита": {
|
||||||
animation: "repayment",
|
animation: "repayment",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
|
variant: "repayment",
|
||||||
},
|
},
|
||||||
"подписки": {
|
"подписки": {
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
|
variant: "subscriptions",
|
||||||
},
|
},
|
||||||
"покупки": {
|
"покупки": {
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
|
variant: "shopping",
|
||||||
},
|
},
|
||||||
"продукты": {
|
"продукты": {
|
||||||
animation: "basket",
|
animation: "basket",
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
|
variant: "basket",
|
||||||
},
|
},
|
||||||
"путешествия": {
|
"путешествия": {
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
|
variant: "travel",
|
||||||
},
|
},
|
||||||
"развлечения": {
|
"развлечения": {
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
|
variant: "music",
|
||||||
},
|
},
|
||||||
"связь и интернет": {
|
"связь и интернет": {
|
||||||
animation: "wifi",
|
animation: "wifi",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
|
variant: "wifi",
|
||||||
},
|
},
|
||||||
"снятие наличных": {
|
"снятие наличных": {
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
|
variant: "cashWithdrawal",
|
||||||
},
|
},
|
||||||
"ставки": {
|
"ставки": {
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
|
variant: "dice",
|
||||||
},
|
},
|
||||||
"страхование": {
|
"страхование": {
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
|
variant: "shield",
|
||||||
},
|
},
|
||||||
"табак": {
|
"табак": {
|
||||||
color: "orange",
|
color: "orange",
|
||||||
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
|
variant: "smoking",
|
||||||
},
|
},
|
||||||
"такси": {
|
"такси": {
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
|
variant: "taxi",
|
||||||
},
|
},
|
||||||
"транспорт": {
|
"транспорт": {
|
||||||
animation: "car",
|
animation: "car",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
|
variant: "car",
|
||||||
},
|
},
|
||||||
"хостинг и ии-сервисы": {
|
"хостинг и ии-сервисы": {
|
||||||
animation: "server",
|
animation: "server",
|
||||||
color: "grape",
|
color: "grape",
|
||||||
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
|
variant: "server",
|
||||||
},
|
},
|
||||||
"цифровые покупки": {
|
"цифровые покупки": {
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
|
variant: "download",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const otherIcon: OperationIcon = {
|
const otherIcon: OperationIcon = {
|
||||||
animation: "grid",
|
animation: "grid",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
|
variant: "grid",
|
||||||
};
|
};
|
||||||
|
|
||||||
const operationIcons: Record<
|
const operationIcons: Record<
|
||||||
Exclude<BankOperation["kind"], "expense">,
|
Exclude<BankOperation["kind"], "expense">,
|
||||||
OperationIcon
|
OperationIcon
|
||||||
> = {
|
> = {
|
||||||
refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" },
|
refund: { color: "teal", variant: "refund" },
|
||||||
topup: {
|
topup: {
|
||||||
animation: "topup",
|
animation: "topup",
|
||||||
color: "green",
|
color: "green",
|
||||||
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
|
variant: "topup",
|
||||||
},
|
},
|
||||||
transfer: {
|
transfer: {
|
||||||
animation: "transfer",
|
animation: "transfer",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
path: "M3 7h18l-4-4 M21 17H3l4 4",
|
variant: "transfer",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,12 +134,18 @@ function bankOperationAppearance(
|
|||||||
kind: BankOperation["kind"],
|
kind: BankOperation["kind"],
|
||||||
categoryName?: null | string,
|
categoryName?: null | string,
|
||||||
) {
|
) {
|
||||||
if (kind !== "expense") return /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ operationIcons[kind];
|
if (kind !== "expense")
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
|
||||||
|
return operationIcons[kind];
|
||||||
|
|
||||||
const name = categoryName
|
const name = categoryName
|
||||||
?.trim()
|
?.trim()
|
||||||
.toLocaleLowerCase("ru-RU")
|
.toLocaleLowerCase("ru-RU")
|
||||||
.replaceAll("ё", "е");
|
.replaceAll("ё", "е");
|
||||||
return name && Object.hasOwn(categoryIcons, name) ? /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ categoryIcons[name] : otherIcon;
|
|
||||||
|
return name && Object.hasOwn(categoryIcons, name)
|
||||||
|
? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
|
||||||
|
: otherIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { bankOperationAppearance };
|
export { bankOperationAppearance };
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ it.each([
|
|||||||
it("rejects zero year consistently in legacy and explicit periods", () => {
|
it("rejects zero year consistently in legacy and explicit periods", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
|
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
|
||||||
|
|
||||||
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
|
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
|
||||||
expect(bankPeriod(new URLSearchParams(query))).toEqual({
|
expect(bankPeriod(new URLSearchParams(query))).toEqual({
|
||||||
from: "2026-09-01",
|
from: "2026-09-01",
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { z } from "zod";
|
import { calendarDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { bankMonth } from "./form-validation";
|
import { bankMonth } from "./form-validation";
|
||||||
|
|
||||||
const calendarDate = z.iso.date();
|
|
||||||
const ISO_DATE_LENGTH = 10;
|
const ISO_DATE_LENGTH = 10;
|
||||||
|
|
||||||
function bankPeriod(parameters: URLSearchParams) {
|
function bankPeriod(parameters: URLSearchParams) {
|
||||||
const month = bankMonth(parameters.get("month"));
|
const month = bankMonth(parameters.get("month"));
|
||||||
const monthEnd = new Date(`${month}-01T00:00:00Z`);
|
const monthEnd = new Date(`${month}-01T00:00:00Z`);
|
||||||
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
|
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
|
||||||
const from = parameters.get("from");
|
const from = parameters.get("from");
|
||||||
const to = parameters.get("to");
|
const to = parameters.get("to");
|
||||||
|
|
||||||
return from
|
return from
|
||||||
&& to
|
&& to
|
||||||
&& calendarDate.safeParse(from).success
|
&& calendarDate.safeParse(from).success
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { SimpleGrid } from "@mantine/core";
|
|||||||
|
|
||||||
import { BankCardFace } from "./BankCardFace";
|
import { BankCardFace } from "./BankCardFace";
|
||||||
|
|
||||||
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
|
||||||
|
const meta
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
card: {
|
card: {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -23,34 +25,45 @@ const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
|
|||||||
component: BankCardFace,
|
component: BankCardFace,
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
title: "Product/BankCards/CardAppearance",
|
title: "Product/BankCards/CardAppearance",
|
||||||
}
|
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankCardFace>;
|
||||||
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankCardFace>;
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Banks: Story = {
|
export const Banks: Story = {
|
||||||
render: arguments_ => (
|
render: arguments_ => (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
|
||||||
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null].map(
|
{[
|
||||||
(bankName, index) => (
|
"Сбербанк",
|
||||||
|
"Т-Банк",
|
||||||
|
"Альфа-Банк",
|
||||||
|
"ВТБ",
|
||||||
|
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||||
|
null,
|
||||||
|
].map((bankName, index) => (
|
||||||
<BankCardFace
|
<BankCardFace
|
||||||
card={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
card={
|
||||||
{
|
{
|
||||||
...arguments_.card,
|
...arguments_.card,
|
||||||
bankName,
|
bankName,
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ (["mir", "mastercard", "visa", "mir", null] as const)[index]
|
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
|
||||||
|
(["mir", "mastercard", "visa", "mir", null] as const)[index]
|
||||||
?? null,
|
?? null,
|
||||||
}
|
}
|
||||||
/* eslint-enable unicorn/no-null */)}
|
/* eslint-enable unicorn/no-null */
|
||||||
|
}
|
||||||
key={bankName ?? "unknown"}
|
key={bankName ?? "unknown"}
|
||||||
/>
|
/>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
export const MissingDetails: Story
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
card: {
|
card: {
|
||||||
...meta.args.card,
|
...meta.args.card,
|
||||||
@@ -59,8 +72,8 @@ export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixtu
|
|||||||
paymentSystem: null,
|
paymentSystem: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
/* eslint-enable unicorn/no-null */
|
||||||
export const LongArchived: Story = {
|
export const LongArchived: Story = {
|
||||||
args: {
|
args: {
|
||||||
card: {
|
card: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { ContactlessIcon } from "@wealthledger/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
@@ -9,51 +10,46 @@ import type { BankCard } from "../../api/queries";
|
|||||||
import classes from "./BankCardFace.module.css";
|
import classes from "./BankCardFace.module.css";
|
||||||
|
|
||||||
type BankCardFaceProps = { card: BankCard };
|
type BankCardFaceProps = { card: BankCard };
|
||||||
|
|
||||||
const BankCardFace: FC<BankCardFaceProps> = (props) => {
|
const BankCardFace: FC<BankCardFaceProps> = (props) => {
|
||||||
const { card } = props;
|
const { card } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
|
const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
|
||||||
const tone = bankTone(bank);
|
const tone = bankTone(bank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.face} data-tone={tone}>
|
<div className={classes.face} data-tone={tone}>
|
||||||
<div className={classes.top}>
|
<div className={classes.top}>
|
||||||
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
|
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
|
||||||
|
|
||||||
{card.archivedAt && (
|
{card.archivedAt && (
|
||||||
<span className={classes.archive}>{t("bank.archived")}</span>
|
<span className={classes.archive}>{t("bank.archived")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div aria-hidden="true" className={classes.hardware}>
|
<div aria-hidden="true" className={classes.hardware}>
|
||||||
<span className={classes.chip} />
|
<span className={classes.chip} />
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
<ContactlessIcon height="30" viewBox="0 0 24 30" />
|
||||||
fill="none"
|
|
||||||
height="30"
|
|
||||||
viewBox="0 0 24 30"
|
|
||||||
width="24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={classes.number}
|
<div className={classes.number}>
|
||||||
>
|
|
||||||
{`•••• •••• •••• ${card.lastFour || "••••"}`}
|
{`•••• •••• •••• ${card.lastFour || "••••"}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.bottom}>
|
<div className={classes.bottom}>
|
||||||
<div className={classes.details}>
|
<div className={classes.details}>
|
||||||
<div className={classes.name}>{card.name}</div>
|
<div className={classes.name}>{card.name}</div>
|
||||||
|
|
||||||
<div className={classes.balance}>{formatMoney(card.balance)}</div>
|
<div className={classes.balance}>{formatMoney(card.balance)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{card.paymentSystem && (
|
{card.paymentSystem && (
|
||||||
<span className={classes.network} data-network={card.paymentSystem}>
|
<span className={classes.network} data-network={card.paymentSystem}>
|
||||||
{card.paymentSystem === "mastercard" && (
|
{card.paymentSystem === "mastercard" && (
|
||||||
<span aria-hidden="true" className={classes.circles} />
|
<span aria-hidden="true" className={classes.circles} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{t(`bank.paymentSystems.${card.paymentSystem}`)}
|
{t(`bank.paymentSystems.${card.paymentSystem}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -66,6 +62,7 @@ function bankTone(bank: string) {
|
|||||||
if (/сбер|sber/.test(bank)) return "green";
|
if (/сбер|sber/.test(bank)) return "green";
|
||||||
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
|
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
|
||||||
if (/альфа|alfa/.test(bank)) return "red";
|
if (/альфа|alfa/.test(bank)) return "red";
|
||||||
|
|
||||||
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
|
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import { useController, useFormContext } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { bankDateInputValue } from "../../model/form-validation";
|
||||||
|
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
|
||||||
|
|
||||||
|
function useBankCardFieldsVm() {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
register,
|
||||||
|
} = useFormContext<BankCardValues>();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { field: paymentSystemField } = useController({
|
||||||
|
control,
|
||||||
|
name: "paymentSystem",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { field: openingDateField } = useController({
|
||||||
|
control,
|
||||||
|
name: "openingDate",
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||||
|
label: t(`bank.paymentSystems.${value}`),
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onPaymentSystemChange: (value: null | string) => void = value =>
|
||||||
|
paymentSystemField.onChange(value ?? "");
|
||||||
|
|
||||||
|
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
||||||
|
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||||
|
? bankDateInputValue(value)
|
||||||
|
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
|
||||||
|
|
||||||
|
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
|
||||||
|
event,
|
||||||
|
) => {
|
||||||
|
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
|
openingDateField.onBlur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpeningDateChange: (value: null | string) => void = value =>
|
||||||
|
openingDateField.onChange(value ?? "");
|
||||||
|
|
||||||
|
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||||
|
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
|
|
||||||
|
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
|
||||||
|
.success
|
||||||
|
? openingDateField.value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateParser,
|
||||||
|
errors,
|
||||||
|
onOpeningDateBlur,
|
||||||
|
onOpeningDateChange,
|
||||||
|
onOpeningDateInput,
|
||||||
|
onPaymentSystemChange,
|
||||||
|
openingDateField,
|
||||||
|
openingDateValue,
|
||||||
|
paymentSystemField,
|
||||||
|
paymentSystems,
|
||||||
|
register,
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBankCardFieldsVm };
|
||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||||||
|
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import type { BankCardValues } from "../../model/form-validation";
|
import type { BankCardValues } from "../../model/form.schema";
|
||||||
|
|
||||||
import { BankCardFields } from "./BankCardForm.view";
|
import { BankCardFields } from "./BankCardForm.view";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ const meta = {
|
|||||||
openingDate: "2026-08-01",
|
openingDate: "2026-08-01",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Story />
|
<Story />
|
||||||
@@ -38,8 +39,11 @@ const meta = {
|
|||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
title: "Product/BankCards/CardFields",
|
title: "Product/BankCards/CardFields",
|
||||||
} satisfies Meta<typeof BankCardFields>;
|
} satisfies Meta<typeof BankCardFields>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Active: Story = { name: "Независимая карта в рублях" };
|
export const Active: Story = { name: "Независимая карта в рублях" };
|
||||||
export const Archived: Story = {
|
export const Archived: Story = {
|
||||||
args: { archived: true },
|
args: { archived: true },
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries";
|
|||||||
|
|
||||||
import { BankCardForm } from "./BankCardForm";
|
import { BankCardForm } from "./BankCardForm";
|
||||||
|
|
||||||
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
|
||||||
|
const card: BankCard
|
||||||
|
= {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
balance: { currency: "RUB", value: "50" },
|
balance: { currency: "RUB", value: "50" },
|
||||||
bankName: "Банк",
|
bankName: "Банк",
|
||||||
@@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
|
|||||||
openingDate: "2026-01-01",
|
openingDate: "2026-01-01",
|
||||||
paymentSystem: null,
|
paymentSystem: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
|
/* eslint-enable unicorn/no-null */
|
||||||
function setup(value: BankCard) {
|
function setup(value: BankCard) {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "test",
|
accessToken: () => "test",
|
||||||
@@ -41,8 +44,10 @@ function setup(value: BankCard) {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return close;
|
return close;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BankCardForm", () => {
|
describe("BankCardForm", () => {
|
||||||
it("waits for the exit transition before notifying the parent", async () => {
|
it("waits for the exit transition before notifying the parent", async () => {
|
||||||
const close = setup(card);
|
const close = setup(card);
|
||||||
@@ -64,20 +69,23 @@ describe("BankCardForm", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
|
||||||
return HttpResponse.json(card);
|
return HttpResponse.json(card);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||||
expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
expect(body).toEqual(
|
||||||
{
|
{
|
||||||
bankName: null,
|
bankName: null,
|
||||||
lastFour: null,
|
lastFour: null,
|
||||||
name: "Основная",
|
name: "Основная",
|
||||||
paymentSystem: null,
|
paymentSystem: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */));
|
/* eslint-enable unicorn/no-null */
|
||||||
|
);
|
||||||
});
|
});
|
||||||
it("loads and saves the selected payment system", async () => {
|
it("loads and saves the selected payment system", async () => {
|
||||||
const close = setup({ ...card, paymentSystem: "mir" });
|
const close = setup({ ...card, paymentSystem: "mir" });
|
||||||
@@ -92,6 +100,7 @@ describe("BankCardForm", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
|
||||||
return HttpResponse.json({ ...card, paymentSystem: "visa" });
|
return HttpResponse.json({ ...card, paymentSystem: "visa" });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -132,6 +141,7 @@ describe("BankCardForm", () => {
|
|||||||
const body = (await request.json()) as Record<string, unknown>;
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
bodies.push(body);
|
bodies.push(body);
|
||||||
version = 2;
|
version = 2;
|
||||||
|
|
||||||
return body.version === 1
|
return body.version === 1
|
||||||
? HttpResponse.json(
|
? HttpResponse.json(
|
||||||
{ code: "BANK_VERSION_CONFLICT" },
|
{ code: "BANK_VERSION_CONFLICT" },
|
||||||
@@ -146,9 +156,11 @@ describe("BankCardForm", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
await screen.findByText(/Запись уже изменена/);
|
await screen.findByText(/Запись уже изменена/);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
|
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
|
||||||
|
|
||||||
const adopt = await screen.findByRole("button", {
|
const adopt = await screen.findByRole("button", {
|
||||||
name: "Использовать актуальную версию 2",
|
name: "Использовать актуальную версию 2",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
|
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
|
||||||
fireEvent.click(adopt);
|
fireEvent.click(adopt);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
|
|||||||
@@ -1,121 +1,61 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { type FC, useState } from "react";
|
import { type FC } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { formatDate } from "@/shared/lib";
|
import { formatDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { useBankMutations } from "../../api/mutations";
|
import { type BankCard } from "../../api/queries";
|
||||||
import { type BankCard, useBankCard } from "../../api/queries";
|
|
||||||
import {
|
|
||||||
bankCardSchema,
|
|
||||||
type BankCardValues,
|
|
||||||
bankMoneyValue,
|
|
||||||
bankToday,
|
|
||||||
} from "../../model/form-validation";
|
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
import classes from "../BankForms.module.css";
|
||||||
import { useBankFormFocus } from "../useBankFormFocus";
|
|
||||||
import { BankCardFields } from "./BankCardForm.view";
|
import { BankCardFields } from "./BankCardForm.view";
|
||||||
|
import { useBankCardFormVm } from "./BankCardForm.vm";
|
||||||
|
|
||||||
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
||||||
|
|
||||||
const BankCardForm: FC<BankCardFormProps> = (props) => {
|
const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||||
useBankFormFocus();
|
const {
|
||||||
const [opened, setOpened] = useState(true);
|
dialogTitle,
|
||||||
const { t } = useTranslation();
|
error,
|
||||||
const [intent] = useState(() => crypto.randomUUID());
|
isPending,
|
||||||
const [version, setVersion] = useState(props.card?.version ?? 1);
|
latest,
|
||||||
const [error, setError] = useState<unknown>();
|
methods,
|
||||||
const [latest, setLatest] = useState<BankCard>();
|
onAdoptVersion,
|
||||||
const fresh = useBankCard(props.card?.id);
|
onCancel,
|
||||||
const mutation = useBankMutations();
|
onReloadRecord,
|
||||||
const methods = useForm<BankCardValues>({
|
onRequestClose,
|
||||||
defaultValues: {
|
onSubmit,
|
||||||
bankName: props.card?.bankName ?? "",
|
opened,
|
||||||
lastFour: props.card?.lastFour ?? "",
|
t,
|
||||||
name: props.card?.name ?? "",
|
} = useBankCardFormVm(props);
|
||||||
openingBalance: props.card?.openingBalance.value ?? "0",
|
|
||||||
openingDate: props.card?.openingDate ?? bankToday(),
|
|
||||||
paymentSystem: props.card?.paymentSystem ?? "",
|
|
||||||
},
|
|
||||||
resolver: zodResolver(bankCardSchema),
|
|
||||||
});
|
|
||||||
const isPending
|
|
||||||
= !opened
|
|
||||||
|| methods.formState.isSubmitting
|
|
||||||
|| (fresh.isFetching && Boolean(error));
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
||||||
closeOnClickOutside={!isPending}
|
closeOnClickOutside={!isPending}
|
||||||
closeOnEscape={!isPending}
|
closeOnEscape={!isPending}
|
||||||
onClose={() => !isPending && setOpened(false)}
|
onClose={onRequestClose}
|
||||||
onExitTransitionEnd={props.onClose}
|
onExitTransitionEnd={props.onClose}
|
||||||
opened={opened}
|
opened={opened}
|
||||||
title={t(props.card ? "bank.editCard" : "bank.addCard")}
|
title={dialogTitle}
|
||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form onSubmit={onSubmit}>
|
||||||
onSubmit={methods.handleSubmit(async (values) => {
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
const metadata = {
|
|
||||||
bankName: values.bankName || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
|
|
||||||
lastFour: values.lastFour || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
|
|
||||||
name: values.name,
|
|
||||||
paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
|
|
||||||
};
|
|
||||||
const financial = {
|
|
||||||
openingBalance: {
|
|
||||||
currency: "RUB" as const,
|
|
||||||
value: bankMoneyValue(values.openingBalance),
|
|
||||||
},
|
|
||||||
openingDate: values.openingDate,
|
|
||||||
};
|
|
||||||
if (props.card)
|
|
||||||
await mutation.updateCard.mutateAsync({
|
|
||||||
body: {
|
|
||||||
...metadata,
|
|
||||||
...(!props.card.archivedAt && financial),
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
id: props.card.id,
|
|
||||||
});
|
|
||||||
else
|
|
||||||
await mutation.createCard.mutateAsync({
|
|
||||||
id: intent,
|
|
||||||
...metadata,
|
|
||||||
...financial,
|
|
||||||
});
|
|
||||||
setOpened(false);
|
|
||||||
}
|
|
||||||
catch (error_) {
|
|
||||||
setError(error_);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
||||||
{!!(error) && ((
|
|
||||||
|
{!!error && (
|
||||||
<>
|
<>
|
||||||
<BankFeedback error={error} />
|
<BankFeedback error={error} />
|
||||||
|
|
||||||
{props.card && (
|
{props.card && (
|
||||||
<Button
|
<Button onClick={onReloadRecord} variant="default">
|
||||||
onClick={async () => {
|
|
||||||
const response = await fresh.refetch();
|
|
||||||
if (response.data && !response.error)
|
|
||||||
setLatest(response.data);
|
|
||||||
else setError(response.error);
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("bank.reloadRecord")}
|
{t("bank.reloadRecord")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{latest && (
|
{latest && (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
@@ -123,23 +63,19 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
|
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
<Button onClick={onAdoptVersion} variant="default">
|
||||||
setVersion(latest.version);
|
|
||||||
setLatest(undefined);
|
|
||||||
setError(undefined);
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("bank.adoptVersion", { version: latest.version })}
|
{t("bank.adoptVersion", { version: latest.version })}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button loading={isPending} type="submit">
|
<Button loading={isPending} type="submit">
|
||||||
{t("bank.save")}
|
{t("bank.save")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setOpened(false)} variant="default">
|
|
||||||
|
<Button onClick={onCancel} variant="default">
|
||||||
{t("bank.cancel")}
|
{t("bank.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -2,24 +2,28 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { Select, Stack, Text, TextInput } from "@mantine/core";
|
import { Select, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import { bankToday } from "../../model/form-validation";
|
||||||
type BankCardValues,
|
import { useBankCardFieldsVm } from "./BankCardFields.vm";
|
||||||
bankDateInputValue,
|
|
||||||
bankDateSchema,
|
|
||||||
bankToday,
|
|
||||||
} from "../../model/form-validation";
|
|
||||||
|
|
||||||
type BankCardFieldsProps = { archived: boolean };
|
type BankCardFieldsProps = { archived: boolean };
|
||||||
|
|
||||||
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
control,
|
dateParser,
|
||||||
formState: { errors },
|
errors,
|
||||||
|
onOpeningDateBlur,
|
||||||
|
onOpeningDateChange,
|
||||||
|
onOpeningDateInput,
|
||||||
|
onPaymentSystemChange,
|
||||||
|
openingDateField,
|
||||||
|
openingDateValue,
|
||||||
|
paymentSystemField,
|
||||||
|
paymentSystems,
|
||||||
register,
|
register,
|
||||||
} = useFormContext<BankCardValues>();
|
t,
|
||||||
const { t } = useTranslation();
|
} = useBankCardFieldsVm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -28,37 +32,36 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
|||||||
{...register("name")}
|
{...register("name")}
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("bank.bankName")}
|
label={t("bank.bankName")}
|
||||||
{...register("bankName")}
|
{...register("bankName")}
|
||||||
error={errors.bankName?.message}
|
error={errors.bankName?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
label={t("bank.lastFour")}
|
label={t("bank.lastFour")}
|
||||||
{...register("lastFour")}
|
{...register("lastFour")}
|
||||||
error={errors.lastFour?.message}
|
error={errors.lastFour?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="paymentSystem"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...paymentSystemField}
|
||||||
clearable
|
clearable
|
||||||
comboboxProps={{ withinPortal: false }}
|
comboboxProps={{ withinPortal: false }}
|
||||||
data={["mir", "visa", "mastercard"].map(value => ({
|
data={paymentSystems}
|
||||||
label: t(`bank.paymentSystems.${value}`),
|
|
||||||
value,
|
|
||||||
}))}
|
|
||||||
error={errors.paymentSystem?.message}
|
error={errors.paymentSystem?.message}
|
||||||
label={t("bank.paymentSystem")}
|
label={t("bank.paymentSystem")}
|
||||||
onChange={value => field.onChange(value ?? "")}
|
onChange={onPaymentSystemChange}
|
||||||
placeholder={t("bank.paymentSystemUnknown")}
|
placeholder={t("bank.paymentSystemUnknown")}
|
||||||
value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
|
value={
|
||||||
/>
|
paymentSystemField.value
|
||||||
)}
|
// eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
|
||||||
|
|| null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
label={t("bank.openingBalance")}
|
label={t("bank.openingBalance")}
|
||||||
@@ -67,43 +70,31 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
|||||||
{...register("openingBalance")}
|
{...register("openingBalance")}
|
||||||
error={errors.openingBalance?.message}
|
error={errors.openingBalance?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="openingDate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<DateInput
|
<DateInput
|
||||||
{...field}
|
{...openingDateField}
|
||||||
dateParser={value =>
|
dateParser={dateParser}
|
||||||
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
|
||||||
? bankDateInputValue(value)
|
|
||||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
|
|
||||||
error={errors.openingDate?.message}
|
error={errors.openingDate?.message}
|
||||||
fixOnBlur={false}
|
fixOnBlur={false}
|
||||||
label={t("bank.openingDate")}
|
label={t("bank.openingDate")}
|
||||||
maxDate={bankToday()}
|
maxDate={bankToday()}
|
||||||
onBlur={(event) => {
|
onBlur={onOpeningDateBlur}
|
||||||
field.onChange(bankDateInputValue(event.currentTarget.value));
|
onChange={onOpeningDateChange}
|
||||||
field.onBlur();
|
onInput={onOpeningDateInput}
|
||||||
}}
|
|
||||||
onChange={value => field.onChange(value ?? "")}
|
|
||||||
onInput={event =>
|
|
||||||
field.onChange(bankDateInputValue(event.currentTarget.value))}
|
|
||||||
popoverProps={{
|
popoverProps={{
|
||||||
middlewares: { shift: { crossAxis: true } },
|
middlewares: { shift: { crossAxis: true } },
|
||||||
withinPortal: false,
|
withinPortal: false,
|
||||||
}}
|
}}
|
||||||
readOnly={props.archived}
|
readOnly={props.archived}
|
||||||
required
|
required
|
||||||
value={
|
value={openingDateValue}
|
||||||
(bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined
|
|
||||||
}
|
|
||||||
valueFormat="DD.MM.YYYY"
|
valueFormat="DD.MM.YYYY"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("bank.openingHint")}
|
{t("bank.openingHint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
|
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useBankMutations } from "../../api/mutations";
|
||||||
|
import { type BankCard, useBankCard } from "../../api/queries";
|
||||||
|
import { bankMoneyValue, bankToday } from "../../model/form-validation";
|
||||||
|
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
||||||
|
import { useBankFormFocus } from "../useBankFormFocus";
|
||||||
|
|
||||||
|
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
||||||
|
|
||||||
|
function useBankCardFormVm(props: BankCardFormProps) {
|
||||||
|
useBankFormFocus();
|
||||||
|
const [opened, setOpened] = useState(true);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [intent] = useState(() => crypto.randomUUID());
|
||||||
|
const [version, setVersion] = useState(props.card?.version ?? 1);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [latest, setLatest] = useState<BankCard>();
|
||||||
|
const fresh = useBankCard(props.card?.id);
|
||||||
|
const mutation = useBankMutations();
|
||||||
|
|
||||||
|
const methods = useForm<BankCardValues>({
|
||||||
|
defaultValues: {
|
||||||
|
bankName: props.card?.bankName ?? "",
|
||||||
|
lastFour: props.card?.lastFour ?? "",
|
||||||
|
name: props.card?.name ?? "",
|
||||||
|
openingBalance: props.card?.openingBalance.value ?? "0",
|
||||||
|
openingDate: props.card?.openingDate ?? bankToday(),
|
||||||
|
paymentSystem: props.card?.paymentSystem ?? "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(bankCardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending
|
||||||
|
= !opened
|
||||||
|
|| methods.formState.isSubmitting
|
||||||
|
|| (fresh.isFetching && Boolean(error));
|
||||||
|
|
||||||
|
const onRequestClose = () => !isPending && setOpened(false);
|
||||||
|
const dialogTitle = t(props.card ? "bank.editCard" : "bank.addCard");
|
||||||
|
|
||||||
|
const onSubmit = methods.handleSubmit(async (values) => {
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = {
|
||||||
|
bankName:
|
||||||
|
values.bankName
|
||||||
|
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||||
|
|| null,
|
||||||
|
lastFour:
|
||||||
|
values.lastFour
|
||||||
|
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||||
|
|| null,
|
||||||
|
name: values.name,
|
||||||
|
paymentSystem:
|
||||||
|
values.paymentSystem
|
||||||
|
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||||
|
|| null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const financial = {
|
||||||
|
openingBalance: {
|
||||||
|
currency: "RUB" as const,
|
||||||
|
value: bankMoneyValue(values.openingBalance),
|
||||||
|
},
|
||||||
|
openingDate: values.openingDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.card)
|
||||||
|
await mutation.updateCard.mutateAsync({
|
||||||
|
body: {
|
||||||
|
...metadata,
|
||||||
|
...(!props.card.archivedAt && financial),
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
id: props.card.id,
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await mutation.createCard.mutateAsync({
|
||||||
|
id: intent,
|
||||||
|
...metadata,
|
||||||
|
...financial,
|
||||||
|
});
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
catch (error_) {
|
||||||
|
setError(error_);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onReloadRecord = async () => {
|
||||||
|
const response = await fresh.refetch();
|
||||||
|
if (response.data && !response.error) setLatest(response.data);
|
||||||
|
else setError(response.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdoptVersion = () => {
|
||||||
|
if (!latest) return;
|
||||||
|
setVersion(latest.version);
|
||||||
|
setLatest(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => setOpened(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialogTitle,
|
||||||
|
error,
|
||||||
|
isPending,
|
||||||
|
latest,
|
||||||
|
methods,
|
||||||
|
onAdoptVersion,
|
||||||
|
onCancel,
|
||||||
|
onReloadRecord,
|
||||||
|
onRequestClose,
|
||||||
|
onSubmit,
|
||||||
|
opened,
|
||||||
|
t,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBankCardFormVm };
|
||||||
@@ -11,9 +11,11 @@ type BankFeedbackProps = {
|
|||||||
loadingError?: boolean;
|
loadingError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const code = apiErrorCode(props.error);
|
const code = apiErrorCode(props.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert color="orange" role="alert">
|
<Alert color="orange" role="alert">
|
||||||
<Group>
|
<Group>
|
||||||
@@ -26,6 +28,7 @@ const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{props.retry && (
|
{props.retry && (
|
||||||
<Button onClick={props.retry} variant="default">
|
<Button onClick={props.retry} variant="default">
|
||||||
{t("bank.retry")}
|
{t("bank.retry")}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
|
import { OperationIcon } from "@wealthledger/icons";
|
||||||
import { EntryCard } from "@wealthledger/ui";
|
import { EntryCard } from "@wealthledger/ui";
|
||||||
|
|
||||||
import { BankOperationIcon } from "./BankOperationIcon";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
|
|
||||||
|
const BankOperationIcon = (props: {
|
||||||
|
categoryName?: null | string;
|
||||||
|
kind: "expense" | "refund" | "topup" | "transfer";
|
||||||
|
}) => (
|
||||||
|
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||||
|
);
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
args: { categoryName: "Продукты", kind: "expense" },
|
args: { categoryName: "Продукты", kind: "expense" },
|
||||||
@@ -11,8 +19,11 @@ const meta = {
|
|||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
title: "Product/BankCards/OperationIcons",
|
title: "Product/BankCards/OperationIcons",
|
||||||
} satisfies Meta<typeof BankOperationIcon>;
|
} satisfies Meta<typeof BankOperationIcon>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Categories: Story = {
|
export const Categories: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Stack maw={640}>
|
<Stack maw={640}>
|
||||||
@@ -55,6 +66,7 @@ export const Categories: Story = {
|
|||||||
valueLabel="Расход"
|
valueLabel="Расход"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<EntryCard
|
<EntryCard
|
||||||
color="green"
|
color="green"
|
||||||
icon={<BankOperationIcon kind="topup" />}
|
icon={<BankOperationIcon kind="topup" />}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
|
import { OperationIcon } from "@wealthledger/icons";
|
||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import { BankOperationIcon } from "./BankOperationIcon";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
|
|
||||||
|
const BankOperationIcon = (props: {
|
||||||
|
categoryName?: null | string;
|
||||||
|
kind: "expense" | "refund" | "topup" | "transfer";
|
||||||
|
}) => (
|
||||||
|
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||||
|
);
|
||||||
|
|
||||||
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
|
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<BankOperationIcon categoryName="Жильё" kind="expense" />,
|
<BankOperationIcon categoryName="Жильё" kind="expense" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||||
const path = () => container.querySelector("path")?.getAttribute("d");
|
const path = () => container.querySelector("path")?.getAttribute("d");
|
||||||
const housing = path();
|
const housing = path();
|
||||||
@@ -15,16 +24,25 @@ it("matches normalized category names, falls back after renaming and distinguish
|
|||||||
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
|
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
|
||||||
const fallback = path();
|
const fallback = path();
|
||||||
expect(fallback).not.toBe(housing);
|
expect(fallback).not.toBe(housing);
|
||||||
for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
|
|
||||||
|
for (const categoryName of [
|
||||||
|
"Моя категория",
|
||||||
|
"constructor",
|
||||||
|
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
||||||
expect(path()).toBe(fallback);
|
expect(path()).toBe(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths = [fallback, housing];
|
const paths = [fallback, housing];
|
||||||
|
|
||||||
for (const kind of ["topup", "transfer", "refund"] as const) {
|
for (const kind of ["topup", "transfer", "refund"] as const) {
|
||||||
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
|
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
|
||||||
expect(path()).toBeTruthy();
|
expect(path()).toBeTruthy();
|
||||||
paths.push(path());
|
paths.push(path());
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(new Set(paths).size).toBe(5);
|
expect(new Set(paths).size).toBe(5);
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||||
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
||||||
@@ -34,8 +52,10 @@ it("provides a distinct icon for every existing named category", () => {
|
|||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<BankOperationIcon categoryName="Другое" kind="expense" />,
|
<BankOperationIcon categoryName="Другое" kind="expense" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||||
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
|
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
|
||||||
|
|
||||||
for (const categoryName of [
|
for (const categoryName of [
|
||||||
"Алкоголь",
|
"Алкоголь",
|
||||||
"Вклады",
|
"Вклады",
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { bindIconAnimation } from "@/shared/lib";
|
|
||||||
|
|
||||||
import type { BankOperation } from "../../api/queries";
|
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
|
||||||
import classes from "./BankOperationIcon.module.css";
|
|
||||||
|
|
||||||
type BankOperationIconProps = {
|
|
||||||
categoryName?: null | string;
|
|
||||||
kind: BankOperation["kind"];
|
|
||||||
};
|
|
||||||
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
|
|
||||||
const icon = bankOperationAppearance(props.kind, props.categoryName);
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height="24"
|
|
||||||
ref={bindIconAnimation}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.6"
|
|
||||||
style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
>
|
|
||||||
{icon.path.split(" M").map((part, index) => (
|
|
||||||
<path d={index ? `M${part}` : part} key={part} pathLength={1} />
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { BankOperationIcon };
|
|
||||||
+7
-15
@@ -1,29 +1,21 @@
|
|||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { type FC, useState } from "react";
|
import { type FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
import { useBankPeriodPickerVm } from "./BankPeriodPicker.vm";
|
||||||
|
|
||||||
type BankPeriodPickerProps = {
|
type BankPeriodPickerProps = {
|
||||||
onChange: (value: [null | string, null | string]) => void;
|
onChange: (value: [null | string, null | string]) => void;
|
||||||
period: { from: string; to: string };
|
period: { from: string; to: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
|
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
|
||||||
const { onChange, period } = props;
|
const { onPeriodChange, t, value } = useBankPeriodPickerVm(props);
|
||||||
const { t } = useTranslation();
|
|
||||||
const scope = `${period.from}-${period.to}`;
|
|
||||||
const [draft, setDraft] = useState<{
|
|
||||||
scope: string;
|
|
||||||
value: [null | string, null | string];
|
|
||||||
}>();
|
|
||||||
const value: [null | string, null | string]
|
|
||||||
= draft?.scope === scope ? draft.value : [period.from, period.to];
|
|
||||||
return (
|
return (
|
||||||
<DatePickerInput
|
<DatePickerInput
|
||||||
allowSingleDateInRange
|
allowSingleDateInRange
|
||||||
label={t("bank.period")}
|
label={t("bank.period")}
|
||||||
onChange={(next) => {
|
onChange={onPeriodChange}
|
||||||
setDraft({ scope, value: next });
|
|
||||||
onChange(next);
|
|
||||||
}}
|
|
||||||
popoverProps={{
|
popoverProps={{
|
||||||
middlewares: { shift: { crossAxis: true } },
|
middlewares: { shift: { crossAxis: true } },
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type BankPeriodPickerProps = {
|
||||||
|
onChange: (value: [null | string, null | string]) => void;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function useBankPeriodPickerVm(props: BankPeriodPickerProps) {
|
||||||
|
const { onChange, period } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const scope = `${period.from}-${period.to}`;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<{
|
||||||
|
scope: string;
|
||||||
|
value: [null | string, null | string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const value: [null | string, null | string]
|
||||||
|
= draft?.scope === scope ? draft.value : [period.from, period.to];
|
||||||
|
|
||||||
|
const onPeriodChange = (next: [null | string, null | string]) => {
|
||||||
|
setDraft({ scope, value: next });
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { onChange, onPeriodChange, t, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBankPeriodPickerVm };
|
||||||
@@ -3,8 +3,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||||||
import { BankSummaryView } from "./BankSummary.view";
|
import { BankSummaryView } from "./BankSummary.view";
|
||||||
|
|
||||||
const money = (value: string) => ({ currency: "RUB", value });
|
const money = (value: string) => ({ currency: "RUB", value });
|
||||||
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
|
|
||||||
|
const meta
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
data: {
|
data: {
|
||||||
cardId: null,
|
cardId: null,
|
||||||
@@ -59,13 +62,16 @@ const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
|
|||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
title: "Product/BankCards/Summary",
|
title: "Product/BankCards/Summary",
|
||||||
}
|
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankSummaryView>;
|
||||||
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankSummaryView>;
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Loaded: Story = { name: "Расходы по категориям" };
|
export const Loaded: Story = { name: "Расходы по категориям" };
|
||||||
export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
export const RefundMonth: Story
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
data: {
|
data: {
|
||||||
cardId: null,
|
cardId: null,
|
||||||
@@ -86,10 +92,11 @@ export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: "Возврат покупки прошлого месяца",
|
name: "Возврат покупки прошлого месяца",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
/* eslint-enable unicorn/no-null */
|
||||||
export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
export const Empty: Story
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
data: {
|
data: {
|
||||||
cardId: null,
|
cardId: null,
|
||||||
@@ -101,8 +108,8 @@ export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: "Известные нули",
|
name: "Известные нули",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
/* eslint-enable unicorn/no-null */
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: { data: undefined, loading: true },
|
args: { data: undefined, loading: true },
|
||||||
name: "Начальная загрузка",
|
name: "Начальная загрузка",
|
||||||
@@ -115,8 +122,9 @@ export const CachedError: Story = {
|
|||||||
args: { error: true },
|
args: { error: true },
|
||||||
name: "Сохранённые суммы при ошибке обновления",
|
name: "Сохранённые суммы при ошибке обновления",
|
||||||
};
|
};
|
||||||
export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||||
{
|
export const LongAmounts: Story
|
||||||
|
= {
|
||||||
args: {
|
args: {
|
||||||
data: {
|
data: {
|
||||||
cardId: null,
|
cardId: null,
|
||||||
@@ -136,5 +144,5 @@ export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: "Точный итог длиннее предела отдельного ввода",
|
name: "Точный итог длиннее предела отдельного ввода",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
/* eslint-enable unicorn/no-null */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CSSProperties, FC } from "react";
|
import type { CSSProperties, FC } from "react";
|
||||||
|
|
||||||
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
|
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { OperationIcon } from "@wealthledger/icons";
|
||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ import { formatMoney } from "@/shared/lib";
|
|||||||
|
|
||||||
import type { BankSummary } from "../../api/queries";
|
import type { BankSummary } from "../../api/queries";
|
||||||
|
|
||||||
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
import classes from "./BankSummary.module.css";
|
import classes from "./BankSummary.module.css";
|
||||||
|
|
||||||
const CATEGORY_COLUMNS = 2;
|
const CATEGORY_COLUMNS = 2;
|
||||||
@@ -21,10 +22,10 @@ type BankSummaryViewProps = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
};
|
||||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|
||||||
|
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
function renderCategories() {
|
|
||||||
if (props.data) {
|
if (props.data) {
|
||||||
if (props.data.categories.length > 0) {
|
if (props.data.categories.length > 0) {
|
||||||
return (
|
return (
|
||||||
@@ -32,19 +33,22 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|||||||
className={classes.categories}
|
className={classes.categories}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--category-rows": Math.ceil(props.data.categories.length / CATEGORY_COLUMNS),
|
"--category-rows": Math.ceil(
|
||||||
|
props.data.categories.length / CATEGORY_COLUMNS,
|
||||||
|
),
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{props.data.categories.map(category => (
|
{props.data.categories.map(category => (
|
||||||
<div className={classes.category} key={category.categoryId}>
|
<div className={classes.category} key={category.categoryId}>
|
||||||
<dt className={classes.label}>
|
<dt className={classes.label}>
|
||||||
<BankOperationIcon
|
<OperationIcon
|
||||||
categoryName={category.categoryName}
|
{...bankOperationAppearance("expense", category.categoryName)}
|
||||||
kind="expense"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span>{category.categoryName}</span>
|
<span>{category.categoryName}</span>
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
<dd className={classes.amount}>
|
<dd className={classes.amount}>
|
||||||
{formatMoney(category.net, { withZeroCents: true })}
|
{formatMoney(category.net, { withZeroCents: true })}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -53,45 +57,58 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("bank.noExpenses")}
|
{t("bank.noExpenses")}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.loading) {
|
if (props.loading) {
|
||||||
return (
|
return (
|
||||||
<LoadingSkeleton label={t("bank.loading")}>
|
<LoadingSkeleton label={t("bank.loading")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Skeleton h={32} w="35%" />
|
<Skeleton h={32} w="35%" />
|
||||||
|
|
||||||
<Skeleton h={40} />
|
<Skeleton h={40} />
|
||||||
|
|
||||||
<Skeleton h={40} />
|
<Skeleton h={40} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack aria-busy={props.loading || props.fetching} gap="md">
|
<Stack aria-busy={props.loading || props.fetching} gap="md">
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Title order={2}>
|
<Title order={2}>
|
||||||
{t(props.all ? "bank.allSummary" : "bank.summary")}
|
{t(props.all ? "bank.allSummary" : "bank.summary")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{props.data && (
|
{props.data && (
|
||||||
<Text className={classes.total}>
|
<Text className={classes.total}>
|
||||||
{formatMoney(props.data.net, { withZeroCents: true })}
|
{formatMoney(props.data.net, { withZeroCents: true })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderCategories()}
|
|
||||||
|
<BankSummaryViewCategories {...props} />
|
||||||
|
|
||||||
{props.error && (
|
{props.error && (
|
||||||
<Group role="alert">
|
<Group role="alert">
|
||||||
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
|
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
|
||||||
|
|
||||||
<Button onClick={props.retry} variant="default">
|
<Button onClick={props.retry} variant="default">
|
||||||
{t("bank.retry")}
|
{t("bank.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{props.data && props.fetching && (
|
{props.data && props.fetching && (
|
||||||
<Text role="status">{t("bank.refreshing")}</Text>
|
<Text role="status">{t("bank.refreshing")}</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,18 +23,23 @@ describe("CategoryManager", () => {
|
|||||||
clear: () => {},
|
clear: () => {},
|
||||||
refresh: () => Promise.resolve(false),
|
refresh: () => Promise.resolve(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: "a", isDefault: false, name: "Первая", version: 1 },
|
{ id: "a", isDefault: false, name: "Первая", version: 1 },
|
||||||
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
|
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
let reads = 0;
|
let reads = 0;
|
||||||
|
|
||||||
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
|
const { promise: reloaded, resolve: release }
|
||||||
|
= Promise.withResolvers<void>();
|
||||||
|
|
||||||
const patches: { body: unknown; id: unknown }[] = [];
|
const patches: { body: unknown; id: unknown }[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/bank-cards/categories", async () => {
|
http.get("*/api/v1/bank-cards/categories", async () => {
|
||||||
reads++;
|
reads++;
|
||||||
if (reads === 3) await reloaded;
|
if (reads === 3) await reloaded;
|
||||||
|
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
categories.map(category =>
|
categories.map(category =>
|
||||||
category.id === "a" && reads > 1
|
category.id === "a" && reads > 1
|
||||||
@@ -51,6 +56,7 @@ describe("CategoryManager", () => {
|
|||||||
"*/api/v1/bank-cards/categories/:id",
|
"*/api/v1/bank-cards/categories/:id",
|
||||||
async ({ params, request }) => {
|
async ({ params, request }) => {
|
||||||
patches.push({ body: await request.json(), id: params.id });
|
patches.push({ body: await request.json(), id: params.id });
|
||||||
|
|
||||||
return patches.length === 1
|
return patches.length === 1
|
||||||
? HttpResponse.json(
|
? HttpResponse.json(
|
||||||
{ code: "BANK_VERSION_CONFLICT" },
|
{ code: "BANK_VERSION_CONFLICT" },
|
||||||
@@ -67,7 +73,11 @@ describe("CategoryManager", () => {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
|
|
||||||
|
const renameButtons = await screen.findAllByRole("button", {
|
||||||
|
name: "Переименовать",
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(renameButtons[0]);
|
fireEvent.click(renameButtons[0]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
@@ -122,6 +132,7 @@ describe("CategoryManager", () => {
|
|||||||
),
|
),
|
||||||
http.patch("*/api/v1/bank-cards/categories/a", () => {
|
http.patch("*/api/v1/bank-cards/categories/a", () => {
|
||||||
version = 2;
|
version = 2;
|
||||||
|
|
||||||
return ++writes === 1
|
return ++writes === 1
|
||||||
? HttpResponse.json(
|
? HttpResponse.json(
|
||||||
{ code: "BANK_VERSION_CONFLICT" },
|
{ code: "BANK_VERSION_CONFLICT" },
|
||||||
@@ -163,12 +174,14 @@ describe("CategoryManager", () => {
|
|||||||
clear: () => {},
|
clear: () => {},
|
||||||
refresh: () => Promise.resolve(false),
|
refresh: () => Promise.resolve(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
let category = {
|
let category = {
|
||||||
id: "default",
|
id: "default",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: "Другое",
|
name: "Другое",
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
let creates = 0;
|
let creates = 0;
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
server.use(
|
server.use(
|
||||||
@@ -177,6 +190,7 @@ describe("CategoryManager", () => {
|
|||||||
),
|
),
|
||||||
http.post("*/api/v1/bank-cards/categories", () => {
|
http.post("*/api/v1/bank-cards/categories", () => {
|
||||||
creates++;
|
creates++;
|
||||||
|
|
||||||
return HttpResponse.json({});
|
return HttpResponse.json({});
|
||||||
}),
|
}),
|
||||||
http.patch(
|
http.patch(
|
||||||
@@ -184,6 +198,7 @@ describe("CategoryManager", () => {
|
|||||||
async ({ request }) => {
|
async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
category = { ...category, name: "Остальное", version: 2 };
|
category = { ...category, name: "Остальное", version: 2 };
|
||||||
|
|
||||||
return HttpResponse.json(category);
|
return HttpResponse.json(category);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -8,218 +7,148 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { type FC, useRef, useState } from "react";
|
import { OperationIcon } from "@wealthledger/icons";
|
||||||
import {
|
import { type FC } from "react";
|
||||||
Controller,
|
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
||||||
FormProvider,
|
|
||||||
useForm,
|
|
||||||
useFormContext,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
|
|
||||||
import { useBankMutations } from "../../api/mutations";
|
|
||||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
import classes from "../BankForms.module.css";
|
||||||
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
|
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
||||||
import { useBankFormFocus } from "../useBankFormFocus";
|
|
||||||
|
|
||||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
|
||||||
|
|
||||||
type CategoryManagerProps = {
|
type CategoryManagerProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated?: (id: string) => void;
|
onCreated?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryValues = { name: string };
|
type CategoryValues = { name: string };
|
||||||
|
|
||||||
const CategoryFields: FC = () => {
|
const CategoryFields: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useFormContext<CategoryValues>();
|
} = useFormContext<CategoryValues>();
|
||||||
|
|
||||||
|
const { field: nameField } = useController({ control, name: "name" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("bank.categoryName")}
|
label={t("bank.categoryName")}
|
||||||
required
|
required
|
||||||
{...field}
|
{...nameField}
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||||
useBankFormFocus();
|
const {
|
||||||
const [opened, setOpened] = useState(true);
|
categories,
|
||||||
const { t } = useTranslation();
|
categoryRows,
|
||||||
const categories = useBankCategories();
|
editing,
|
||||||
const mutation = useBankMutations();
|
error,
|
||||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
isPending,
|
||||||
const editSessionRef = useRef(0);
|
latest,
|
||||||
const [error, setError] = useState<unknown>();
|
methods,
|
||||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
onAdoptVersion,
|
||||||
const schema = z.object({
|
onCancel,
|
||||||
name: z
|
onDismiss,
|
||||||
.string()
|
onReloadRecord,
|
||||||
.trim()
|
onRequestClose,
|
||||||
.refine(
|
onSubmit,
|
||||||
value =>
|
opened,
|
||||||
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
retryCategories,
|
||||||
{ error: validationMessage("bank.validation.category") },
|
t,
|
||||||
)
|
} = useCategoryManagerVm(props);
|
||||||
.refine(
|
|
||||||
value =>
|
|
||||||
!categories.data?.some(
|
|
||||||
c =>
|
|
||||||
c.id !== editing?.id
|
|
||||||
&& c.name.toLocaleLowerCase("ru-RU")
|
|
||||||
=== value.toLocaleLowerCase("ru-RU"),
|
|
||||||
),
|
|
||||||
{ error: validationMessage("bank.validation.duplicate") },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
|
|
||||||
const isPending = !opened || methods.formState.isSubmitting;
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
||||||
closeOnClickOutside={!isPending}
|
closeOnClickOutside={!isPending}
|
||||||
closeOnEscape={!isPending}
|
closeOnEscape={!isPending}
|
||||||
onClose={() => !isPending && setOpened(false)}
|
onClose={onRequestClose}
|
||||||
onExitTransitionEnd={props.onClose}
|
onExitTransitionEnd={props.onClose}
|
||||||
opened={opened}
|
opened={opened}
|
||||||
title={t("bank.categories")}
|
title={t("bank.categories")}
|
||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form onSubmit={onSubmit}>
|
||||||
onSubmit={(event) => {
|
|
||||||
void methods.handleSubmit(async (values) => {
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
if (editing)
|
|
||||||
await mutation.updateCategory.mutateAsync({
|
|
||||||
body: { name: values.name, version: editing.version },
|
|
||||||
id: editing.id,
|
|
||||||
});
|
|
||||||
else {
|
|
||||||
const result = await mutation.createCategory.mutateAsync({
|
|
||||||
name: values.name,
|
|
||||||
});
|
|
||||||
if (result) props.onCreated?.(result.id);
|
|
||||||
}
|
|
||||||
editSessionRef.current++;
|
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
}
|
|
||||||
catch (error_) {
|
|
||||||
setError(error_);
|
|
||||||
}
|
|
||||||
})(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||||
<Stack>
|
<Stack>
|
||||||
{categories.data?.map(category => (
|
{categoryRows?.map((category) => {
|
||||||
|
return (
|
||||||
<Group justify="space-between" key={category.id}>
|
<Group justify="space-between" key={category.id}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<BankOperationIcon
|
<OperationIcon
|
||||||
categoryName={category.name}
|
{...bankOperationAppearance("expense", category.name)}
|
||||||
kind="expense"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text>{category.name}</Text>
|
<Text>{category.name}</Text>
|
||||||
|
|
||||||
{category.isDefault && (
|
{category.isDefault && (
|
||||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
<Button onClick={category.onRename} variant="subtle">
|
||||||
editSessionRef.current++;
|
|
||||||
setEditing(category);
|
|
||||||
methods.reset({ name: category.name });
|
|
||||||
setError(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
}}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
{t("bank.rename")}
|
{t("bank.rename")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{categories.isLoading && (
|
{categories.isLoading && (
|
||||||
<Text role="status">{t("bank.loading")}</Text>
|
<Text role="status">{t("bank.loading")}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{categories.isError && (
|
{categories.isError && (
|
||||||
<BankFeedback
|
<BankFeedback
|
||||||
cached={Boolean(categories.data)}
|
cached={Boolean(categories.data)}
|
||||||
error={categories.error}
|
error={categories.error}
|
||||||
loadingError
|
loadingError
|
||||||
retry={() => void categories.refetch()}
|
retry={retryCategories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CategoryFields />
|
<CategoryFields />
|
||||||
{!!(error) && ((
|
|
||||||
|
{!!error && (
|
||||||
<>
|
<>
|
||||||
<BankFeedback error={error} />
|
<BankFeedback error={error} />
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<Button
|
<Button onClick={onReloadRecord} variant="default">
|
||||||
onClick={async () => {
|
|
||||||
const session = editSessionRef.current;
|
|
||||||
const response = await categories.refetch();
|
|
||||||
if (session !== editSessionRef.current) return;
|
|
||||||
if (response.error) setError(response.error);
|
|
||||||
else
|
|
||||||
setLatest(
|
|
||||||
response.data?.find(c => c.id === editing.id),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("bank.reloadRecord")}
|
{t("bank.reloadRecord")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{latest && latest.id === editing?.id && (
|
{latest && latest.id === editing?.id && (
|
||||||
<>
|
<>
|
||||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
<Button onClick={onAdoptVersion}>
|
||||||
setEditing(latest);
|
|
||||||
setLatest(undefined);
|
|
||||||
setError(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("bank.adoptVersion", { version: latest.version })}
|
{t("bank.adoptVersion", { version: latest.version })}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button loading={isPending} type="submit">
|
<Button loading={isPending} type="submit">
|
||||||
{t(editing ? "bank.save" : "bank.addCategory")}
|
{t(editing ? "bank.save" : "bank.addCategory")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<Button
|
<Button onClick={onCancel} variant="default">
|
||||||
onClick={() => {
|
|
||||||
editSessionRef.current++;
|
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
setError(undefined);
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("bank.cancel")}
|
{t("bank.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setOpened(false)} variant="default">
|
|
||||||
|
<Button onClick={onDismiss} variant="default">
|
||||||
{t("bank.close")}
|
{t("bank.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useBankMutations } from "../../api/mutations";
|
||||||
|
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||||
|
import { categorySchema } from "../../model/category.schema";
|
||||||
|
import { useBankFormFocus } from "../useBankFormFocus";
|
||||||
|
|
||||||
|
type CategoryManagerProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryValues = { name: string };
|
||||||
|
|
||||||
|
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||||
|
useBankFormFocus();
|
||||||
|
const [opened, setOpened] = useState(true);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const categories = useBankCategories();
|
||||||
|
const mutation = useBankMutations();
|
||||||
|
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||||
|
const editSessionRef = useRef(0);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||||
|
const schema = categorySchema(categories.data, editing?.id);
|
||||||
|
|
||||||
|
const methods = useForm<CategoryValues>({
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = !opened || methods.formState.isSubmitting;
|
||||||
|
const onRequestClose = () => !isPending && setOpened(false);
|
||||||
|
|
||||||
|
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||||
|
void methods.handleSubmit(async (values) => {
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editing)
|
||||||
|
await mutation.updateCategory.mutateAsync({
|
||||||
|
body: { name: values.name, version: editing.version },
|
||||||
|
id: editing.id,
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
const result = await mutation.createCategory.mutateAsync({
|
||||||
|
name: values.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) props.onCreated?.(result.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
editSessionRef.current++;
|
||||||
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
methods.reset({ name: "" });
|
||||||
|
}
|
||||||
|
catch (error_) {
|
||||||
|
setError(error_);
|
||||||
|
}
|
||||||
|
})(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryCategories = () => void categories.refetch();
|
||||||
|
|
||||||
|
const onReloadRecord = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const session = editSessionRef.current;
|
||||||
|
const response = await categories.refetch();
|
||||||
|
if (session !== editSessionRef.current) return;
|
||||||
|
if (response.error) setError(response.error);
|
||||||
|
else setLatest(response.data?.find(c => c.id === editing.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdoptVersion = () => {
|
||||||
|
setEditing(latest);
|
||||||
|
setLatest(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
editSessionRef.current++;
|
||||||
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
methods.reset({ name: "" });
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDismiss = () => setOpened(false);
|
||||||
|
|
||||||
|
const categoryRows = categories.data?.map((category) => {
|
||||||
|
const onRename = () => {
|
||||||
|
editSessionRef.current++;
|
||||||
|
setEditing(category);
|
||||||
|
methods.reset({ name: category.name });
|
||||||
|
setError(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...category, onRename };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
categoryRows,
|
||||||
|
editing,
|
||||||
|
error,
|
||||||
|
isPending,
|
||||||
|
latest,
|
||||||
|
methods,
|
||||||
|
onAdoptVersion,
|
||||||
|
onCancel,
|
||||||
|
onDismiss,
|
||||||
|
onReloadRecord,
|
||||||
|
onRequestClose,
|
||||||
|
onSubmit,
|
||||||
|
opened,
|
||||||
|
retryCategories,
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCategoryManagerVm };
|
||||||
@@ -18,6 +18,7 @@ function bondQueryOptions(uid: string) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBond(identifier: string) {
|
function useBond(identifier: string) {
|
||||||
const isUid
|
const isUid
|
||||||
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
@@ -28,6 +29,7 @@ function useBond(identifier: string) {
|
|||||||
...bondQueryOptions(identifier),
|
...bondQueryOptions(identifier),
|
||||||
enabled: Boolean(identifier) && isUid,
|
enabled: Boolean(identifier) && isUid,
|
||||||
});
|
});
|
||||||
|
|
||||||
const byTicker = useResourceQuery({
|
const byTicker = useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
||||||
params: { path: { ticker: identifier } },
|
params: { path: { ticker: identifier } },
|
||||||
@@ -35,6 +37,7 @@ function useBond(identifier: string) {
|
|||||||
enabled: Boolean(identifier) && !isUid,
|
enabled: Boolean(identifier) && !isUid,
|
||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return isUid ? byUid : byTicker;
|
return isUid ? byUid : byTicker;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ function useBondCandles(uid: string, period: BondPeriod) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondCoupons(uid: string) {
|
function useBondCoupons(uid: string) {
|
||||||
return useResourceQuery({
|
return useResourceQuery({
|
||||||
meta: { accessGroup: "brokerage" },
|
meta: { accessGroup: "brokerage" },
|
||||||
@@ -57,6 +61,7 @@ function useBondCoupons(uid: string) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondMetrics(uid: string) {
|
function useBondMetrics(uid: string) {
|
||||||
return useResourceQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
|
||||||
@@ -66,6 +71,7 @@ function useBondMetrics(uid: string) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondPosition(uid: string) {
|
function useBondPosition(uid: string) {
|
||||||
return useResourceQuery({
|
return useResourceQuery({
|
||||||
meta: { accessGroup: "brokerage" },
|
meta: { accessGroup: "brokerage" },
|
||||||
@@ -76,6 +82,7 @@ function useBondPosition(uid: string) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondQuote(uid: string) {
|
function useBondQuote(uid: string) {
|
||||||
return useResourceQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
|
||||||
@@ -85,6 +92,7 @@ function useBondQuote(uid: string) {
|
|||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondSearch(query: string) {
|
function useBondSearch(query: string) {
|
||||||
return useResourceQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
|
||||||
@@ -95,7 +103,8 @@ function useBondSearch(query: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type BondPeriod,
|
export {
|
||||||
|
type BondPeriod,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
useBondCandles,
|
useBondCandles,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { type BondPeriod,
|
export {
|
||||||
|
type BondPeriod,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
useBondCandles,
|
useBondCandles,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user