refactor: enforce frontend readability and UI boundaries #43
+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,33 @@
|
|||||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||||
|
|
||||||
|
## Читаемость: ESLint и review
|
||||||
|
|
||||||
|
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||||
|
JSX siblings и вокруг многострочных declarations/control-flow/return, вынос
|
||||||
|
функций и условных выражений из JSX props, отдельные Zod schema files,
|
||||||
|
отсутствие SVG в приложении, optional trailing parameters и многострочные
|
||||||
|
сложные union types. Generated artifacts не форматируй вручную.
|
||||||
|
URL, строковые/template/regex literals и комментарии исключены из `max-len`;
|
||||||
|
это исключение для содержимого, а не повод собирать длинные expressions.
|
||||||
|
Однострочный JSX не требует обрамляющих скобок; многострочный — требует.
|
||||||
|
`unicorn/no-null` отключён: API DTO, React refs и Mantine используют `null`.
|
||||||
|
Сохраняй различие `null`/`undefined` из контракта вместо массовых suppressions.
|
||||||
|
CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки.
|
||||||
|
|
||||||
|
Review проверяет то, чего синтаксис не доказывает:
|
||||||
|
|
||||||
|
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||||
|
- Имена derived values описывают значение, например `projectedAmount`.
|
||||||
|
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||||
|
и многострочное преобразование — отдельная функция с guard и `if/else`.
|
||||||
|
- Union из object shapes оформляй несколькими строками; nullable scalar
|
||||||
|
`null | string` остаётся компактным. Для повторяемого shape используй named type.
|
||||||
|
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
|
||||||
|
перед следующим required параметром может сохранять `T | undefined`.
|
||||||
|
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
|
||||||
|
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
|
||||||
|
|
||||||
## Hooks и derived state
|
## Hooks и derived state
|
||||||
|
|
||||||
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
|
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
|
||||||
|
|||||||
+43
-11
@@ -11,13 +11,17 @@ Mantine component и общую тему. Для выпадающего выбо
|
|||||||
ограничения JSDOM решай в test setup, сохраняя production UI.
|
ограничения JSDOM решай в test setup, сохраняя production UI.
|
||||||
Семантический HTML для структуры страницы остаётся уместным.
|
Семантический HTML для структуры страницы остаётся уместным.
|
||||||
|
|
||||||
Icons ищи у существующего owner (например,
|
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
|
||||||
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
|
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
|
||||||
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
|
иконки из model сущности. Каждый SVG хранится цельным декларативным
|
||||||
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
|
компонентом: не разбивай path-строки и не собирай геометрию в runtime.
|
||||||
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
|
Русские category names из API — данные для нормализованного сопоставления,
|
||||||
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
|
не ключи реестра иконок; неизвестные/переименованные категории имеют fallback.
|
||||||
components живут у feature/entity/page, даже при повторном использовании.
|
Известные названия банков принадлежат enum `BankName` в bank-card model;
|
||||||
|
свободный ввод пользовательского названия остаётся строкой.
|
||||||
|
SVG декоративны по умолчанию; доступное имя action
|
||||||
|
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
|
||||||
|
и доступное имя. Приложение и Storybook подключают CSS пакета.
|
||||||
|
|
||||||
Wrapper нужен для проектной ответственности, а не ради нового имени
|
Wrapper нужен для проектной ответственности, а не ради нового имени
|
||||||
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
||||||
@@ -51,10 +55,34 @@ retry callbacks, domain grouping и derived presentation data. View получа
|
|||||||
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
|
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
|
||||||
| `Component.module.css` | Локальные стили |
|
| `Component.module.css` | Локальные стили |
|
||||||
|
|
||||||
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
|
View получает готовые данные и именованные действия. Container вызывает VM;
|
||||||
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
|
VM владеет запросами, формой, состоянием, обработчиками и derived values.
|
||||||
image fallback) и formatter одного переданного значения. Группировка/доли
|
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
|
||||||
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
|
вызывает container; presentation hook не должен скрывать fetching во view.
|
||||||
|
Presentation VM для form-context/UI-only state может использоваться автономно
|
||||||
|
в каталоге: её граф зависимостей не достигает application data/API.
|
||||||
|
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||||
|
|
||||||
|
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||||
|
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||||
|
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||||
|
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
|
||||||
|
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
|
||||||
|
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
|
||||||
|
|
||||||
|
Внутри компонента нет именованных helpers. Чистые функции форматирования и
|
||||||
|
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
|
||||||
|
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
|
||||||
|
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
||||||
|
|
||||||
|
VM собирает связные предметные hooks, когда внутри смешиваются независимые
|
||||||
|
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями
|
||||||
|
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook
|
||||||
|
возвращает данные и действия своей ответственности, VM объединяет их для view.
|
||||||
|
Не группируй несвязанные функции только по префиксу `on*` и не создавай
|
||||||
|
passthrough hook над одним вызовом ради формального дробления.
|
||||||
|
|
||||||
|
Render-helper замени самостоятельным presentation component у того же owner.
|
||||||
|
|
||||||
Отдельный state/section с самостоятельной responsibility выноси в
|
Отдельный state/section с самостоятельной responsibility выноси в
|
||||||
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
||||||
@@ -87,6 +115,10 @@ image fallback) и formatter одного переданного значени
|
|||||||
|
|
||||||
## Формы
|
## Формы
|
||||||
|
|
||||||
|
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
|
||||||
|
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
|
||||||
|
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
|
||||||
|
|
||||||
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
|
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
|
||||||
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
|
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
|
||||||
Во время submit блокируй все действия формы. Zod errors показывай у поля;
|
Во время submit блокируй все действия формы. Zod errors показывай у поля;
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ module.exports = {
|
|||||||
forbidden: [
|
forbidden: [
|
||||||
...base.forbidden,
|
...base.forbidden,
|
||||||
{
|
{
|
||||||
from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` },
|
from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" },
|
||||||
name: "stories-must-not-reach-application-data",
|
name: "stories-must-not-reach-application-data",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: {
|
to: {
|
||||||
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
|
path: [
|
||||||
|
|
|||||||
|
"^src/(shared/api|features/auth|app/routes|app/ui)/",
|
||||||
|
"^src/(entities|features)/[^/]+/api/",
|
||||||
|
"^src/pages/[^/]+/ui/.*Page[.]tsx$",
|
||||||
|
"node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
|
||||||
|
],
|
||||||
reachable: true,
|
reachable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 } };
|
||||||
+165
-45
@@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
|||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
import frontend from "./eslint-rules.mjs";
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
|
plugins: { frontend },
|
||||||
|
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
|
||||||
|
},
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
|
"**/dist/**",
|
||||||
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
|
"dist-ssr/**",
|
||||||
|
"storybook-static/**",
|
||||||
|
"coverage/**",
|
||||||
|
".vitest/**",
|
||||||
|
".scannerwork/**",
|
||||||
|
"src/shared/api/generated/**",
|
||||||
|
"src/shared/api/spec/**",
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
extends: [
|
extends: [
|
||||||
@@ -35,32 +49,91 @@ export default defineConfig(
|
|||||||
security.configs.recommended,
|
security.configs.recommended,
|
||||||
noUnsanitized.configs.recommended,
|
noUnsanitized.configs.recommended,
|
||||||
perfectionist.configs["recommended-natural"],
|
perfectionist.configs["recommended-natural"],
|
||||||
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
|
stylistic.configs.customize({
|
||||||
|
indent: "tab",
|
||||||
|
jsx: true,
|
||||||
|
quotes: "double",
|
||||||
|
semi: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
||||||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||||
rules: {
|
rules: {
|
||||||
// A comma disambiguates generic arrow functions from JSX in TSX.
|
// A comma disambiguates generic arrow functions from JSX in TSX.
|
||||||
"@stylistic/comma-dangle": ["error", {
|
"@stylistic/comma-dangle": [
|
||||||
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
|
"error",
|
||||||
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
|
{
|
||||||
objects: "always-multiline", tuples: "always-multiline",
|
arrays: "always-multiline",
|
||||||
}],
|
enums: "always-multiline",
|
||||||
|
exports: "always-multiline",
|
||||||
|
functions: "always-multiline",
|
||||||
|
generics: "ignore",
|
||||||
|
imports: "always-multiline",
|
||||||
|
objects: "always-multiline",
|
||||||
|
tuples: "always-multiline",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
// Keep text/interpolation together: splitting can change rendered whitespace.
|
// Keep text/interpolation together: splitting can change rendered whitespace.
|
||||||
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
||||||
|
"@stylistic/max-len": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
code: 80,
|
||||||
|
ignoreComments: true,
|
||||||
|
ignoreRegExpLiterals: true,
|
||||||
|
ignoreStrings: true,
|
||||||
|
ignoreTemplateLiterals: true,
|
||||||
|
ignoreTrailingComments: true,
|
||||||
|
ignoreUrls: true,
|
||||||
|
tabWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
||||||
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
||||||
"@stylistic/operator-linebreak": ["error", "before"],
|
"@stylistic/operator-linebreak": ["error", "before"],
|
||||||
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
|
"@stylistic/padding-line-between-statements": [
|
||||||
|
"error",
|
||||||
|
{ blankLine: "always", next: "return", prev: "*" },
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
next: "*",
|
||||||
|
prev: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
next: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||||
|
prev: "*",
|
||||||
|
},
|
||||||
|
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
|
||||||
|
],
|
||||||
|
"no-nested-ternary": "error",
|
||||||
|
"perfectionist/sort-classes": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
groups: ["static-method", "property", "method", "unknown"],
|
||||||
|
type: "natural",
|
||||||
|
},
|
||||||
|
],
|
||||||
// Preserve the repository's component, hook and config file naming conventions.
|
// Preserve the repository's component, hook and config file naming conventions.
|
||||||
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }],
|
"unicorn/filename-case": [
|
||||||
|
"error",
|
||||||
|
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
|
||||||
|
],
|
||||||
// React uses Props/Ref names, including the recommended React ref-name rule.
|
// React uses Props/Ref names, including the recommended React ref-name rule.
|
||||||
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }],
|
"unicorn/name-replacements": [
|
||||||
// React, Mantine and Fetch accept null at their documented call boundaries.
|
"error",
|
||||||
"unicorn/no-null": ["error", { checkArguments: false }],
|
{ replacements: { props: false, ref: false } },
|
||||||
|
],
|
||||||
|
// API DTOs, React refs and Mantine use null as part of their contracts.
|
||||||
|
"unicorn/no-null": "off",
|
||||||
|
"unicorn/prefer-ternary": ["error", "only-single-line"],
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })],
|
"import-x/resolver-next": [
|
||||||
|
createTypeScriptImportResolver({ project: "tsconfig.json" }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,18 +141,37 @@ export default defineConfig(
|
|||||||
languageOptions: { globals: globals.node },
|
languageOptions: { globals: globals.node },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
extends: [
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
importX.flatConfigs.typescript,
|
||||||
|
],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
],
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
|
extends: [
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
|
react.configs["recommended-type-checked"],
|
||||||
|
hooks.configs.flat.recommended,
|
||||||
|
query.configs["flat/recommended"],
|
||||||
|
],
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"packages/*/src/**/*.{ts,tsx}",
|
||||||
|
".storybook/preview.tsx",
|
||||||
|
],
|
||||||
languageOptions: { globals: globals.browser },
|
languageOptions: { globals: globals.browser },
|
||||||
rules: {
|
rules: {
|
||||||
"import-x/exports-last": "error",
|
"import-x/exports-last": "error",
|
||||||
@@ -95,10 +187,16 @@ export default defineConfig(
|
|||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-magic-numbers": ["error", {
|
"@typescript-eslint/no-magic-numbers": [
|
||||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
|
"error",
|
||||||
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
|
{
|
||||||
}],
|
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
|
||||||
|
ignoreArrayIndexes: true,
|
||||||
|
ignoreDefaultValues: true,
|
||||||
|
ignoreNumericLiteralTypes: true,
|
||||||
|
ignoreTypeIndexes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,51 +208,73 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
|
extends: [
|
||||||
|
testingLibrary.configs["flat/react"],
|
||||||
|
jestDom.configs["flat/recommended"],
|
||||||
|
],
|
||||||
files: ["**/*.test.tsx"],
|
files: ["**/*.test.tsx"],
|
||||||
},
|
},
|
||||||
storybook.configs["flat/recommended"],
|
storybook.configs["flat/recommended"],
|
||||||
{
|
{
|
||||||
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
||||||
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
|
rules: {
|
||||||
|
"import-x/exports-last": "off",
|
||||||
|
"import-x/no-default-export": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
"src/**/*.{ts,tsx}",
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
"packages/*/src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
ignores: [
|
|
||||||
"**/*.test.{ts,tsx}",
|
|
||||||
"**/*.stories.tsx",
|
|
||||||
"src/test/**",
|
|
||||||
],
|
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
message: "Use the owning enum member for machine codes and protocol values.",
|
message:
|
||||||
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
|
||||||
|
selector:
|
||||||
|
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use the owning enum member instead of a template machine code.",
|
message: "Name the derived prop value before JSX.",
|
||||||
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
selector:
|
||||||
|
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Use the owning enum member for machine codes and protocol values.",
|
||||||
|
selector:
|
||||||
|
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use an enum member instead of an inline code or protocol value.",
|
message:
|
||||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
"Use the owning enum member instead of a template machine code.",
|
||||||
|
selector:
|
||||||
|
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use an enum member instead of constructing a code or protocol value.",
|
message:
|
||||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
"Use an enum member instead of an inline code or protocol value.",
|
||||||
|
selector:
|
||||||
|
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Use an enum member instead of constructing a code or protocol value.",
|
||||||
|
selector:
|
||||||
|
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Use a computed enum member as the protocol mapping key.",
|
message: "Use a computed enum member as the protocol mapping key.",
|
||||||
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
selector:
|
||||||
|
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Compare codes with their enum members, not string literals.",
|
message:
|
||||||
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
"Compare codes with their enum members, not string literals.",
|
||||||
|
selector:
|
||||||
|
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
}
|
},
|
||||||
|
"./styles.css": "./dist/index.css"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build && tsc -p tsconfig.json"
|
"build": "vite build && tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
.icon {
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
& path {
|
& path {
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { BasketIcon } from "./svg/BasketIcon";
|
||||||
|
import { CarIcon } from "./svg/CarIcon";
|
||||||
|
import { CashWithdrawalIcon } from "./svg/CashWithdrawalIcon";
|
||||||
|
import { CoffeeIcon } from "./svg/CoffeeIcon";
|
||||||
|
import { DepositIcon } from "./svg/DepositIcon";
|
||||||
|
import { DiceIcon } from "./svg/DiceIcon";
|
||||||
|
import { DownloadIcon } from "./svg/DownloadIcon";
|
||||||
|
import { EducationIcon } from "./svg/EducationIcon";
|
||||||
|
import { GridIcon } from "./svg/GridIcon";
|
||||||
|
import { GrowthIcon } from "./svg/GrowthIcon";
|
||||||
|
import { HomeIcon } from "./svg/HomeIcon";
|
||||||
|
import { MedicalIcon } from "./svg/MedicalIcon";
|
||||||
|
import { MusicIcon } from "./svg/MusicIcon";
|
||||||
|
import { RefundIcon } from "./svg/RefundIcon";
|
||||||
|
import { RepaymentIcon } from "./svg/RepaymentIcon";
|
||||||
|
import { ServerIcon } from "./svg/ServerIcon";
|
||||||
|
import { ShieldIcon } from "./svg/ShieldIcon";
|
||||||
|
import { ShoppingIcon } from "./svg/ShoppingIcon";
|
||||||
|
import { SmokingIcon } from "./svg/SmokingIcon";
|
||||||
|
import { SubscriptionsIcon } from "./svg/SubscriptionsIcon";
|
||||||
|
import { TaxiIcon } from "./svg/TaxiIcon";
|
||||||
|
import { TopupIcon } from "./svg/TopupIcon";
|
||||||
|
import { TransferIcon } from "./svg/TransferIcon";
|
||||||
|
import { TravelIcon } from "./svg/TravelIcon";
|
||||||
|
import { WifiIcon } from "./svg/WifiIcon";
|
||||||
|
import { WineIcon } from "./svg/WineIcon";
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
basket: BasketIcon,
|
||||||
|
car: CarIcon,
|
||||||
|
cashWithdrawal: CashWithdrawalIcon,
|
||||||
|
coffee: CoffeeIcon,
|
||||||
|
deposit: DepositIcon,
|
||||||
|
dice: DiceIcon,
|
||||||
|
download: DownloadIcon,
|
||||||
|
education: EducationIcon,
|
||||||
|
grid: GridIcon,
|
||||||
|
growth: GrowthIcon,
|
||||||
|
home: HomeIcon,
|
||||||
|
medical: MedicalIcon,
|
||||||
|
music: MusicIcon,
|
||||||
|
refund: RefundIcon,
|
||||||
|
repayment: RepaymentIcon,
|
||||||
|
ksv741
commented
не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме
|
|||||||
|
server: ServerIcon,
|
||||||
|
shield: ShieldIcon,
|
||||||
|
shopping: ShoppingIcon,
|
||||||
|
smoking: SmokingIcon,
|
||||||
|
subscriptions: SubscriptionsIcon,
|
||||||
|
taxi: TaxiIcon,
|
||||||
|
topup: TopupIcon,
|
||||||
|
transfer: TransferIcon,
|
||||||
|
travel: TravelIcon,
|
||||||
|
wifi: WifiIcon,
|
||||||
|
wine: WineIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
type OperationIconProps = {
|
||||||
|
color: string;
|
||||||
|
variant: keyof typeof icons;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OperationIcon: FC<OperationIconProps> = (props) => {
|
||||||
|
const { color, variant } = props;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed key of the static icon registry.
|
||||||
|
const Icon = icons[variant];
|
||||||
|
|
||||||
|
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OperationIcon, type OperationIconProps };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const BasketIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.basket}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 9l4-6 4 6" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 13v3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M15 13v3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BasketIcon };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const CarIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.car}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 16h8" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 11h12m-6-4v4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CarIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const CashWithdrawalIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 10V3h16v7" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 7h10v14H7V7Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 11h10" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M10 16h4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CashWithdrawalIcon };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const CoffeeIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.coffee}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 22h15" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CoffeeIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const DepositIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.deposit}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DepositIcon };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const DiceIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 7h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M17 7h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 12h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 17h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M17 17h.01" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DiceIcon };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const DownloadIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 3h18v13H3V3Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 21h8" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 16v5" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 6v7" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 10l3 3 3-3" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DownloadIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const EducationIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M22 8v9" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EducationIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const GridIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.grid}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 4h6v6H4V4Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { GridIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const GrowthIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.growth}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 3v18h18" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 5h5v5" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { GrowthIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const HomeIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 11l9-8 9 8" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M5 10v11h14V10" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 21v-8h6v8" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { HomeIcon };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const MedicalIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MedicalIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const MusicIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 9l11-2" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MusicIcon };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const RefundIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M8 4L3 9l5 5" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { RefundIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const RepaymentIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.repayment}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 5h18v14H3V5Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 9h18" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { RepaymentIcon };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const ServerIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.server}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 3h18v7H3V3Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 6.5h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 17.5h.01" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M15 6.5h3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M15 17.5h3" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ServerIcon };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const ShieldIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 12l3 3 5-6" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ShieldIcon };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const ShoppingIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ShoppingIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const SmokingIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 14v5" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SmokingIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const SubscriptionsIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M20 3v4h-4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M4 21v-4h4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SubscriptionsIcon };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const TaxiIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M4 10l2-6h12l2 6" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 18v3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M18 18v3" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 14h2" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 14h2" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M10 4V2h4v2" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TaxiIcon };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const TopupIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.topup}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TopupIcon };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const TransferIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.transfer}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 7h18l-4-4" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M21 17H3l4 4" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TransferIcon };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const TravelIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.icon}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TravelIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const WifiIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.wifi}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 19h.01" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { WifiIcon };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import type { IconProps } from "../../types";
|
||||||
|
|
||||||
|
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||||
|
import classes from "../OperationIcon.module.css";
|
||||||
|
|
||||||
|
const WineIcon: FC<IconProps> = (props) => {
|
||||||
|
const { size = 24, ...svgProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${classes.icon} ${classes.wine}`}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
height={size}
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
{...svgProps}
|
||||||
|
>
|
||||||
|
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M12 13v8" pathLength={1} />
|
||||||
|
|
||||||
|
<path d="M8 21h8" pathLength={1} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { WineIcon };
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
+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}
|
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||||
style={
|
|
||||||
(props.color)
|
|
||||||
? ({
|
|
||||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
withBorder
|
: undefined;
|
||||||
>
|
|
||||||
|
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
||||||
|
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className={classes.row} style={surfaceStyle} withBorder>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -40,26 +41,29 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{props.icon ?? props.initial}
|
{props.icon ?? props.initial}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<Stack className={classes.identity} gap={2}>
|
<Stack className={classes.identity} gap={2}>
|
||||||
<Text fw={700} lineClamp={1}>
|
<Text fw={700} lineClamp={1}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{props.metadata}
|
{props.metadata}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{props.details}
|
{props.details}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack className={classes.value} gap={2}>
|
<Stack className={classes.value} gap={2}>
|
||||||
<Text className={classes[props.tone]} fw={700}>
|
<Text className={classes[props.tone]} fw={700}>
|
||||||
{props.value}
|
{props.value}
|
||||||
</Text>
|
</Text>
|
||||||
<div className={(props.actions) ? (classes.valueFooter) : undefined}>
|
|
||||||
<Text
|
<div className={valueContainerClass}>
|
||||||
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
|
<Text className={valueLabelClass} size="sm">
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{props.valueLabel}
|
{props.valueLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{Boolean(props.actions) && (
|
{Boolean(props.actions) && (
|
||||||
<div className={classes.actions}>{props.actions}</div>
|
<div className={classes.actions}>{props.actions}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const meta = {
|
|||||||
<Paper p="md" withBorder>
|
<Paper p="md" withBorder>
|
||||||
<LoadingSkeleton {...arguments_}>
|
<LoadingSkeleton {...arguments_}>
|
||||||
<Skeleton h={24} w="60%" />
|
<Skeleton h={24} w="60%" />
|
||||||
|
|
||||||
<Skeleton h={72} />
|
<Skeleton h={72} />
|
||||||
|
|
||||||
<Skeleton h={16} w="80%" />
|
<Skeleton h={16} w="80%" />
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -33,5 +35,7 @@ const meta = {
|
|||||||
} satisfies Meta<typeof LoadingSkeleton>;
|
} satisfies Meta<typeof LoadingSkeleton>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Initial: Story = { name: "Загрузка области" };
|
export const Initial: Story = { name: "Загрузка области" };
|
||||||
|
|||||||
@@ -41,16 +41,17 @@ const meta = {
|
|||||||
},
|
},
|
||||||
render: (arguments_) => {
|
render: (arguments_) => {
|
||||||
const { onAction, withAction, ...stateProps } = arguments_;
|
const { onAction, withAction, ...stateProps } = arguments_;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
{...stateProps}
|
{...stateProps}
|
||||||
action={
|
action={
|
||||||
(withAction)
|
withAction
|
||||||
? ((
|
? (
|
||||||
<Button onClick={onAction} variant="default">
|
<Button onClick={onAction} variant="default">
|
||||||
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
|
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
|
||||||
</Button>
|
</Button>
|
||||||
))
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -63,6 +64,7 @@ const meta = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Empty: Story = { name: "Пустое состояние" };
|
export const Empty: Story = { name: "Пустое состояние" };
|
||||||
export const EmptyWithAction: Story = {
|
export const EmptyWithAction: Story = {
|
||||||
args: { withAction: true },
|
args: { withAction: true },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+139
-10
@@ -16,11 +16,13 @@ import storybook from "eslint-plugin-storybook";
|
|||||||
import testingLibrary from "eslint-plugin-testing-library";
|
import testingLibrary from "eslint-plugin-testing-library";
|
||||||
import unicorn from "eslint-plugin-unicorn";
|
import unicorn from "eslint-plugin-unicorn";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { globSync } from "node:fs";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
|
|
||||||
for (const [filePath, code, rule] of [
|
for (const [filePath, code, rule] of [
|
||||||
[
|
[
|
||||||
"src/shared/api/problem-details.ts",
|
"src/shared/api/problem-details.ts",
|
||||||
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
rule,
|
rule,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"src/shared/api/generated/schema.d.ts",
|
"src/shared/api/generated/schema.d.ts",
|
||||||
"packages/ui/dist/index.js",
|
"packages/ui/dist/index.js",
|
||||||
@@ -117,19 +120,22 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
"coverage/index.js",
|
"coverage/index.js",
|
||||||
])
|
])
|
||||||
assert.equal(await eslint.isPathIgnored(file), true, file);
|
assert.equal(await eslint.isPathIgnored(file), true, file);
|
||||||
|
|
||||||
const story = await eslint.calculateConfigForFile(
|
const story = await eslint.calculateConfigForFile(
|
||||||
"src/shared/ui/Button.stories.tsx",
|
"src/shared/ui/Button.stories.tsx",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
||||||
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
||||||
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all recommended plugin rules remain enabled in their intended scopes", async () => {
|
test("recommended plugin rules retain the explicit null-contract exception", async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
const source = "src/shared/api/problem-details.ts";
|
const source = "src/shared/api/problem-details.ts";
|
||||||
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
||||||
|
|
||||||
for (const [preset, filePath] of [
|
for (const [preset, filePath] of [
|
||||||
[tseslint.configs.recommendedTypeChecked, source],
|
[tseslint.configs.recommendedTypeChecked, source],
|
||||||
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
||||||
@@ -147,15 +153,34 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
|||||||
[vitest.configs.recommended, unit],
|
[vitest.configs.recommended, unit],
|
||||||
[testingLibrary.configs["flat/react"], unit],
|
[testingLibrary.configs["flat/react"], unit],
|
||||||
[jestDom.configs["flat/recommended"], unit],
|
[jestDom.configs["flat/recommended"], unit],
|
||||||
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
|
...storybook.configs["flat/recommended"]
|
||||||
|
.filter(config => config.rules)
|
||||||
|
.map(config => [
|
||||||
|
config,
|
||||||
|
config.name.endsWith("main-rules")
|
||||||
|
? ".storybook/main.ts"
|
||||||
|
: "src/shared/ui/Button.stories.tsx",
|
||||||
|
]),
|
||||||
]) {
|
]) {
|
||||||
const configs = [preset].flat();
|
const configs = [preset].flat();
|
||||||
const expected = Object.assign({}, ...configs.map(config => config.rules));
|
|
||||||
|
const expected = Object.assign(
|
||||||
|
{},
|
||||||
|
...configs.map(config => config.rules),
|
||||||
|
);
|
||||||
|
|
||||||
const config = await eslint.calculateConfigForFile(filePath);
|
const config = await eslint.calculateConfigForFile(filePath);
|
||||||
const actual = new Map(Object.entries(config.rules));
|
const actual = new Map(Object.entries(config.rules));
|
||||||
|
|
||||||
for (const [rule, setting] of Object.entries(expected)) {
|
for (const [rule, setting] of Object.entries(expected)) {
|
||||||
const severity = Array.isArray(setting) ? setting[0] : setting;
|
const severity = Array.isArray(setting) ? setting[0] : setting;
|
||||||
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
||||||
|
|
||||||
|
if (rule === "unicorn/no-null") {
|
||||||
|
assert.equal(actual.get(rule)?.[0], 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
|
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +189,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
|||||||
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
const [fixed] = await eslint.lintText(
|
||||||
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
||||||
{ filePath },
|
{ filePath },
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(fixed.fatalErrorCount, 0);
|
assert.equal(fixed.fatalErrorCount, 0);
|
||||||
assert.equal(fixed.errorCount, 0);
|
assert.equal(fixed.errorCount, 0);
|
||||||
assert.ok(fixed.output);
|
assert.ok(fixed.output);
|
||||||
@@ -178,10 +205,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async (
|
|||||||
|
|
||||||
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
const [fixed] = await eslint.lintText(
|
||||||
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
||||||
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(fixed.errorCount, 0);
|
assert.equal(fixed.errorCount, 0);
|
||||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
||||||
});
|
});
|
||||||
@@ -190,11 +219,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
|
|||||||
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
const filePath = "src/shared/api/problem-details.ts";
|
const filePath = "src/shared/api/problem-details.ts";
|
||||||
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
|
|
||||||
|
for (const code of [
|
||||||
|
"export function check(code: string) { return code === \"one-off-code\"; }",
|
||||||
|
"export const result = `MOEX_UNAVAILABLE`;",
|
||||||
|
"export const result = { code: \"one-off-code\" };",
|
||||||
|
"export const result = { \"errorCode\": \"one-off-code\" };",
|
||||||
|
"export const result = { code: `dynamic-${Date.now()}` };",
|
||||||
|
"export const result = \"MOEX_UNAVAILABLE\";",
|
||||||
|
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
|
||||||
|
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
|
||||||
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code);
|
assert.ok(
|
||||||
|
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
|
||||||
|
code,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const code of [
|
for (const code of [
|
||||||
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
||||||
"export const select = { code: true, errorCode: true };",
|
"export const select = { code: true, errorCode: true };",
|
||||||
@@ -202,10 +245,96 @@ test("machine codes require enum members without banning ordinary text or Prisma
|
|||||||
]) {
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code);
|
assert.ok(
|
||||||
|
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||||
|
code,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
|
|
||||||
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"));
|
const [fixture] = await eslint.lintText(
|
||||||
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath });
|
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
|
||||||
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison"));
|
{ filePath: "src/shared/api/client.test.ts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [comparison] = await eslint.lintText(
|
||||||
|
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
|
||||||
|
{ filePath },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
comparison.messages.some(
|
||||||
|
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readability enforces syntax without banning VM and schema patterns", async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
|
||||||
|
const model = "src/entities/income/api/useIncome.ts";
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
|
||||||
|
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
|
||||||
|
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
|
||||||
|
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
|
||||||
|
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
|
||||||
|
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
|
||||||
|
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
|
||||||
|
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
|
||||||
|
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
|
||||||
|
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [filePath, code, rule] of invalid) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [filePath, code] of [
|
||||||
|
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
|
||||||
|
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
|
||||||
|
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
|
||||||
|
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
|
||||||
|
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
|
||||||
|
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(globSync("src/**/*.svg"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
|
||||||
|
const eslint = new ESLint({ fix: true });
|
||||||
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
|
||||||
|
const output = fixed.output;
|
||||||
|
assert.ok(output);
|
||||||
|
const [again] = await eslint.lintText(output, { filePath });
|
||||||
|
assert.equal(again.output, undefined);
|
||||||
|
assert.equal(again.errorCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compact JSX and contract nulls need no local suppressions", async () => {
|
||||||
|
const eslint = new ESLint({ fix: true });
|
||||||
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
const code = "import type { FC } from 'react'; type Props = { active: boolean }; const Example: FC<Props> = (props) => props.active ? <span /> : <div />;\nexport { Example };";
|
||||||
|
const [fixed] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(fixed.errorCount, 0);
|
||||||
|
assert.match(fixed.output ?? code, /\? <span \/> : <div \/>/);
|
||||||
|
|
||||||
|
const [nullable] = await eslint.lintText(
|
||||||
|
"export const value: string | null = null;",
|
||||||
|
{ filePath: "src/shared/api/problem-details.ts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(nullable.errorCount, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,23 +20,32 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
|
|||||||
const bin = nodePath.join(cwd, "bin");
|
const bin = nodePath.join(cwd, "bin");
|
||||||
const marker = nodePath.join(cwd, "downstream");
|
const marker = nodePath.join(cwd, "downstream");
|
||||||
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
|
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
|
||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
|
||||||
const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" });
|
const git = (...arguments_) =>
|
||||||
|
execFileSync("git", arguments_, { cwd, stdio: "pipe" }); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||||
|
|
||||||
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
|
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
|
||||||
spawnSync("/bin/sh", ["-e", hook], {
|
spawnSync("/bin/sh", ["-e", hook], {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: { ...process.env, PATH: path },
|
env: { ...process.env, PATH: path },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
mkdirSync(bin);
|
mkdirSync(bin);
|
||||||
|
|
||||||
for (const command of ["npx", "npm"]) {
|
for (const command of ["npx", "npm"]) {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
|
writeFileSync(
|
||||||
mode: 0o755,
|
nodePath.join(bin, command),
|
||||||
});
|
"#!/bin/sh\necho ran >> downstream\n",
|
||||||
|
{
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
git("init", "--quiet");
|
git("init", "--quiet");
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ const accounts = [
|
|||||||
{ id: "a", name: "Основной" },
|
{ id: "a", name: "Основной" },
|
||||||
{ id: "b", name: "ИИС" },
|
{ id: "b", name: "ИИС" },
|
||||||
];
|
];
|
||||||
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
const position: Portfolio["positions"][number]
|
||||||
|
= {
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
currentValue: { currency: "RUB", value: "600" },
|
currentValue: { currency: "RUB", value: "600" },
|
||||||
@@ -40,8 +41,8 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
|
|||||||
quantity: "2",
|
quantity: "2",
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
const portfolio: Portfolio = {
|
const portfolio: Portfolio = {
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
isStale: false,
|
isStale: false,
|
||||||
@@ -50,8 +51,9 @@ const portfolio: Portfolio = {
|
|||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
||||||
};
|
};
|
||||||
const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
const receipt: BrokerageOperation
|
||||||
|
= {
|
||||||
accruedInterest: null,
|
accruedInterest: null,
|
||||||
category: "income",
|
category: "income",
|
||||||
commission: null,
|
commission: null,
|
||||||
@@ -67,11 +69,12 @@ const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixtur
|
|||||||
quantity: null,
|
quantity: null,
|
||||||
ticker: "BOND",
|
ticker: "BOND",
|
||||||
type: "OPERATION_TYPE_COUPON",
|
type: "OPERATION_TYPE_COUPON",
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
const items = accounts.map(account => ({ ...receipt, account }));
|
const items = accounts.map(account => ({ ...receipt, account }));
|
||||||
const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{
|
const analytics: IncomeAnalytics
|
||||||
|
= {
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
bucketGranularity: "day",
|
bucketGranularity: "day",
|
||||||
buckets: [],
|
buckets: [],
|
||||||
@@ -105,17 +108,20 @@ const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
yearComparison: null,
|
yearComparison: null,
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
const CurrentLocation: FC = () => {
|
const CurrentLocation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<output aria-label="Текущий путь">
|
<output aria-label="Текущий путь">
|
||||||
{location.pathname}
|
{location.pathname}
|
||||||
|
|
||||||
{location.search}
|
{location.search}
|
||||||
</output>
|
</output>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPage(path: string) {
|
function renderPage(path: string) {
|
||||||
const client = createTestQueryClient();
|
const client = createTestQueryClient();
|
||||||
render(
|
render(
|
||||||
@@ -124,12 +130,14 @@ function renderPage(path: string) {
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
|
||||||
<CurrentLocation />
|
<CurrentLocation />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +152,16 @@ beforeEach(() => {
|
|||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource(
|
resource(
|
||||||
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
accounts.map(
|
||||||
{
|
|
||||||
...account,
|
account =>
|
||||||
openedOn: null,
|
({
|
||||||
portfolioSummary: null,
|
...account,
|
||||||
type: "BROKERAGE",
|
openedOn: null,
|
||||||
}
|
portfolioSummary: null,
|
||||||
/* eslint-enable unicorn/no-null */))),
|
type: "BROKERAGE",
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -162,15 +172,16 @@ beforeEach(() => {
|
|||||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
({ params }) =>
|
({ params }) =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
|
resource(
|
||||||
{
|
{
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
items,
|
items,
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||||
@@ -181,21 +192,23 @@ beforeEach(() => {
|
|||||||
resource({
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
calculatedAt: receipt.occurredAt,
|
calculatedAt: receipt.occurredAt,
|
||||||
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
events: accounts.map(
|
||||||
{
|
|
||||||
account,
|
account =>
|
||||||
date: "2026-09-15",
|
({
|
||||||
dateKind: "event",
|
account,
|
||||||
displayName: "Облигация",
|
date: "2026-09-15",
|
||||||
figi: null,
|
dateKind: "event",
|
||||||
instrumentUid: null,
|
displayName: "Облигация",
|
||||||
isin: null,
|
figi: null,
|
||||||
logoName: null,
|
instrumentUid: null,
|
||||||
projectedAmount: { currency: "RUB", value: "2" },
|
isin: null,
|
||||||
ticker: "BOND",
|
logoName: null,
|
||||||
type: "coupon",
|
projectedAmount: { currency: "RUB", value: "2" },
|
||||||
}
|
ticker: "BOND",
|
||||||
/* eslint-enable unicorn/no-null */))),
|
type: "coupon",
|
||||||
|
}),
|
||||||
|
),
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotalsByCurrency: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -235,7 +248,11 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
for (const suffix of ["operations", "income", "events"])
|
for (const suffix of ["operations", "income", "events"])
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`),
|
screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.find(
|
||||||
|
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,8 +264,10 @@ describe("All accounts routes", () => {
|
|||||||
({ params, request }) => {
|
({ params, request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
|
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
|
resource(
|
||||||
{
|
{
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: !url.searchParams.has("cursor"),
|
hasNext: !url.searchParams.has("cursor"),
|
||||||
@@ -262,8 +281,8 @@ describe("All accounts routes", () => {
|
|||||||
],
|
],
|
||||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -281,9 +300,11 @@ describe("All accounts routes", () => {
|
|||||||
requests.some(url => url.pathname.includes("/b/operations")),
|
requests.some(url => url.pathname.includes("/b/operations")),
|
||||||
).toBe(true),
|
).toBe(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selected = requests.find(url =>
|
const selected = requests.find(url =>
|
||||||
url.pathname.includes("/b/operations"),
|
url.pathname.includes("/b/operations"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
||||||
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
||||||
expect(selected?.searchParams.get("categories")).toBe("income");
|
expect(selected?.searchParams.get("categories")).toBe("income");
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe("AppRoutes", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/login", async ({ request }) => {
|
http.post("*/api/v1/auth/login", async ({ request }) => {
|
||||||
credentials = await request.json();
|
credentials = await request.json();
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
accessToken: "login-token",
|
accessToken: "login-token",
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
user: { email: "user@example.com", id: "user-1" },
|
||||||
@@ -103,7 +104,8 @@ describe("AppRoutes", () => {
|
|||||||
),
|
),
|
||||||
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
||||||
http.get("*/api/v1/bank-cards/summary", () =>
|
http.get("*/api/v1/bank-cards/summary", () =>
|
||||||
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
|
HttpResponse.json(
|
||||||
{
|
{
|
||||||
cardId: null,
|
cardId: null,
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -111,8 +113,8 @@ describe("AppRoutes", () => {
|
|||||||
month: "2026-08",
|
month: "2026-08",
|
||||||
net: { currency: "RUB", value: "0" },
|
net: { currency: "RUB", value: "0" },
|
||||||
refunds: { currency: "RUB", value: "0" },
|
refunds: { currency: "RUB", value: "0" },
|
||||||
}
|
},
|
||||||
/* eslint-enable unicorn/no-null */)),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderRoutes("/cards?month=2026-08");
|
renderRoutes("/cards?month=2026-08");
|
||||||
|
|||||||
@@ -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,10 @@ const ProtectedDocumentTitle: FC = () => {
|
|||||||
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
|
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
|
||||||
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
|
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
|
||||||
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
|
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
|
||||||
return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
|
|
||||||
|
return settings
|
||||||
|
? <DocumentTitle title={t("navigation.settings")} />
|
||||||
|
: <DocumentTitle title={t("navigation.accounts")} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ProtectedDocumentTitle };
|
export { ProtectedDocumentTitle };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProtectedDocumentTitle />
|
<ProtectedDocumentTitle />
|
||||||
|
|
||||||
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
|
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
|||||||
|
|
||||||
const session = { isAuthenticated: false };
|
const session = { isAuthenticated: false };
|
||||||
vi.mock("@/features/auth", () => ({
|
vi.mock("@/features/auth", () => ({
|
||||||
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
|
||||||
{ user: session.isAuthenticated ? {} : null }
|
useAuth: () =>
|
||||||
/* eslint-enable unicorn/no-null */)),
|
({ user: session.isAuthenticated ? {} : null }),
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ThrowingRoute: FC = () => {
|
const ThrowingRoute: FC = () => {
|
||||||
@@ -24,9 +25,7 @@ const HealthyRoute: FC = () => {
|
|||||||
? (
|
? (
|
||||||
"healthy route"
|
"healthy route"
|
||||||
)
|
)
|
||||||
: (
|
: <ThrowingRoute />}
|
||||||
<ThrowingRoute />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -39,6 +38,7 @@ const RouteHarness: FC = () => {
|
|||||||
<button onClick={() => navigate("/healthy")} type="button">
|
<button onClick={() => navigate("/healthy")} type="button">
|
||||||
go healthy
|
go healthy
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
<HealthyRoute />
|
<HealthyRoute />
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
@@ -62,10 +62,13 @@ describe("RouteErrorBoundary", () => {
|
|||||||
async (path) => {
|
async (path) => {
|
||||||
session.isAuthenticated = path === "/accounts";
|
session.isAuthenticated = path === "/accounts";
|
||||||
let isFailing = true;
|
let isFailing = true;
|
||||||
|
|
||||||
const RecoverableRoute: FC = () => {
|
const RecoverableRoute: FC = () => {
|
||||||
if (isFailing) throw new Error("temporary failure");
|
if (isFailing) throw new Error("temporary failure");
|
||||||
|
|
||||||
return <div>recovered route</div>;
|
return <div>recovered route</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
@@ -116,10 +119,12 @@ describe("RouteErrorBoundary", () => {
|
|||||||
expect(reload).toHaveAttribute("href", "/login");
|
expect(reload).toHaveAttribute("href", "/login");
|
||||||
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
|
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
|
||||||
let isIntercepted = true;
|
let isIntercepted = true;
|
||||||
|
|
||||||
const preventNavigation = (event: MouseEvent) => {
|
const preventNavigation = (event: MouseEvent) => {
|
||||||
isIntercepted = event.defaultPrevented;
|
isIntercepted = event.defaultPrevented;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("click", preventNavigation, { once: true });
|
document.addEventListener("click", preventNavigation, { once: true });
|
||||||
fireEvent.click(reload);
|
fireEvent.click(reload);
|
||||||
expect(isIntercepted).toBe(false);
|
expect(isIntercepted).toBe(false);
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children;
|
return this.state.hasError
|
||||||
|
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||||
|
: (
|
||||||
|
this.props.children
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +50,13 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("routeError.title")}</Title>
|
<Title order={1}>{t("routeError.title")}</Title>
|
||||||
|
|
||||||
<Text>{t("routeError.text")}</Text>
|
<Text>{t("routeError.text")}</Text>
|
||||||
|
|
||||||
<Button component={Link} to={to}>
|
<Button component={Link} to={to}>
|
||||||
{t("routeError.action")}
|
{t("routeError.action")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button component="a" href={to} variant="default">
|
<Button component="a" href={to} variant="default">
|
||||||
{t("routeError.reload")}
|
{t("routeError.reload")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { RouteTransition } from "./RouteTransition";
|
import { RouteTransition } from "./RouteTransition";
|
||||||
|
|
||||||
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
|
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
|
||||||
type RouteSkeletonVariant = "auth" | "content" | "shell";
|
|
||||||
|
type RouteSkeletonVariant = "auth"
|
||||||
|
| "content"
|
||||||
|
| "shell";
|
||||||
|
|
||||||
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||||
const { variant } = props;
|
const { variant } = props;
|
||||||
@@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<Skeleton height={32} width="45%" />
|
<Skeleton height={32} width="45%" />
|
||||||
|
|
||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
|
|
||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
|
|
||||||
<Skeleton height={36} radius="sm" />
|
<Skeleton height={36} radius="sm" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" p="md">
|
<Group justify="space-between" p="md">
|
||||||
<Skeleton height={28} width={160} />
|
<Skeleton height={28} width={160} />
|
||||||
|
|
||||||
<Skeleton height={28} width={96} />
|
<Skeleton height={28} width={96} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Container fluid p="md">
|
<Container fluid p="md">
|
||||||
<Skeleton height={20} width="30%" />
|
<Skeleton height={20} width="30%" />
|
||||||
|
|
||||||
<Stack mt="lg">
|
<Stack mt="lg">
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
|
|
||||||
<Skeleton height={160} radius="md" />
|
<Skeleton height={160} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
|
|
||||||
<Skeleton height={160} radius="md" />
|
<Skeleton height={160} radius="md" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -61,15 +72,14 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
|
|
||||||
const LazyPage: FC<LazyPageProps> = (props) => {
|
const LazyPage: FC<LazyPageProps> = (props) => {
|
||||||
const { children, variant } = props;
|
const { children, variant } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
||||||
{variant === "shell"
|
{variant === "shell"
|
||||||
? (
|
? (
|
||||||
children
|
children
|
||||||
)
|
)
|
||||||
: (
|
: <RouteTransition>{children}</RouteTransition>}
|
||||||
<RouteTransition>{children}</RouteTransition>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ const Protected: FC<PropsWithChildren> = (props) => {
|
|||||||
const { ready, user } = useAuth();
|
const { ready, user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
|
|
||||||
return user
|
return user
|
||||||
? (
|
? <>{children}</>
|
||||||
<>{children}</>
|
|
||||||
)
|
|
||||||
: (
|
: (
|
||||||
<Navigate
|
<Navigate
|
||||||
replace
|
replace
|
||||||
@@ -30,13 +29,17 @@ const Public: FC<PropsWithChildren> = (props) => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
const requested = (location.state as null | { returnTo?: string })?.returnTo;
|
|
||||||
|
const requested = (location.state as null
|
||||||
|
| { returnTo?: string })?.returnTo;
|
||||||
|
|
||||||
const returnTo
|
const returnTo
|
||||||
= typeof requested === "string"
|
= typeof requested === "string"
|
||||||
&& requested.startsWith("/")
|
&& requested.startsWith("/")
|
||||||
&& !requested.startsWith("//")
|
&& !requested.startsWith("//")
|
||||||
? requested
|
? requested
|
||||||
: "/accounts";
|
: "/accounts";
|
||||||
|
|
||||||
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
|
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
+19
-25
@@ -1,6 +1,3 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { Select } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
@@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
|||||||
|
|
||||||
type AccountScopeSwitchProps = { accountId: string; section?: string };
|
type AccountScopeSwitchProps = { accountId: string; section?: string };
|
||||||
|
|
||||||
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
|
function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) {
|
||||||
const { accountId, section } = props;
|
const { accountId, section } = props;
|
||||||
const { data: accounts } = useBrokerageAccounts();
|
const { data: accounts } = useBrokerageAccounts();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ label: t("accounts.all"), value: "all" },
|
{ label: t("accounts.all"), value: "all" },
|
||||||
...(accounts ?? []).map(account => ({
|
...(accounts ?? []).map(account => ({
|
||||||
@@ -21,27 +19,23 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
|
|||||||
value: account.id,
|
value: account.id,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (data.every(item => item.value !== accountId))
|
if (data.every(item => item.value !== accountId))
|
||||||
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
|
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
allowDeselect={false}
|
|
||||||
data={data}
|
|
||||||
label={t("accounts.scope")}
|
|
||||||
maw={360}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!value || value === accountId) return;
|
|
||||||
const filters = new URLSearchParams(search);
|
|
||||||
filters.delete("cursor");
|
|
||||||
void navigate({
|
|
||||||
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
|
|
||||||
search: filters.toString(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
value={accountId}
|
|
||||||
w="100%"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { AccountScopeSwitch };
|
const onChange: (value: null | string) => void = (value) => {
|
||||||
|
if (!value || value === accountId) return;
|
||||||
|
const filters = new URLSearchParams(search);
|
||||||
|
filters.delete("cursor");
|
||||||
|
void navigate({
|
||||||
|
pathname: ["/accounts", encodeURIComponent(value), section]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/"),
|
||||||
|
search: filters.toString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { accountId, data, onChange, t };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useAccountScopeSwitchVm };
|
||||||
@@ -16,6 +16,7 @@ function renderBreadcrumbs(
|
|||||||
accounts?: { id: string; name: string }[],
|
accounts?: { id: string; name: string }[],
|
||||||
) {
|
) {
|
||||||
useBrokerageAccounts.mockReturnValue({ data: accounts });
|
useBrokerageAccounts.mockReturnValue({ data: accounts });
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
@@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => {
|
|||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
|
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
|
||||||
|
|
||||||
for (const separator of screen.getAllByText("→")) {
|
for (const separator of screen.getAllByText("→")) {
|
||||||
expect(separator).toHaveAttribute("aria-hidden", "true");
|
expect(separator).toHaveAttribute("aria-hidden", "true");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMatch } from "react-router";
|
import { useMatch } from "react-router";
|
||||||
|
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
import { AccountScopeSwitch } from "./AccountScopeSwitch";
|
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
||||||
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const operations = useMatch({
|
const operations = useMatch({
|
||||||
end: true,
|
end: true,
|
||||||
path: "/accounts/:accountId/operations",
|
path: "/accounts/:accountId/operations",
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
||||||
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
||||||
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
||||||
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={operations.params.accountId ?? ""}
|
accountId={operations.params.accountId ?? ""}
|
||||||
section="operations"
|
section="operations"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={operations.params.accountId ?? ""}
|
||||||
section="operations"
|
section="operations"
|
||||||
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={events.params.accountId ?? ""}
|
accountId={events.params.accountId ?? ""}
|
||||||
section="events"
|
section="events"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={events.params.accountId ?? ""}
|
accountId={events.params.accountId ?? ""}
|
||||||
section="events"
|
section="events"
|
||||||
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
accountId={income.params.accountId ?? ""}
|
accountId={income.params.accountId ?? ""}
|
||||||
section="income"
|
section="income"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={income.params.accountId ?? ""}
|
accountId={income.params.accountId ?? ""}
|
||||||
section="income"
|
section="income"
|
||||||
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
||||||
|
|
||||||
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -80,7 +86,10 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
|
|
||||||
|
return settings
|
||||||
|
? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
|
||||||
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AppBreadcrumbs };
|
export { AppBreadcrumbs };
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type BondBreadcrumbsProps = { identifier: string };
|
type BondBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useBond(props.identifier);
|
const { data } = useBond(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { Link } from "react-router";
|
|||||||
import classes from "./AppBreadcrumbs.module.css";
|
import classes from "./AppBreadcrumbs.module.css";
|
||||||
|
|
||||||
type BreadcrumbTrailProps = { items: Crumb[] };
|
type BreadcrumbTrailProps = { items: Crumb[] };
|
||||||
|
|
||||||
type Crumb = { label: string; to?: string };
|
type Crumb = { label: string; to?: string };
|
||||||
|
|
||||||
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
||||||
const { items } = props;
|
const { items } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
aria-label={t("breadcrumbs.label")}
|
aria-label={t("breadcrumbs.label")}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useBankCard } from "@/entities/bank-card";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type CardBreadcrumbsProps = { identifier: string };
|
type CardBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useBankCard(props.identifier);
|
const { data } = useBankCard(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { useStock } from "@/entities/stock";
|
|||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
type StockBreadcrumbsProps = { identifier: string };
|
type StockBreadcrumbsProps = { identifier: string };
|
||||||
|
|
||||||
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
||||||
const { data } = useStock(props.identifier);
|
const { data } = useStock(props.identifier);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,45 +77,38 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
<span
|
<span
|
||||||
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
||||||
>
|
>
|
||||||
<svg
|
{item.icon}
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={20}
|
|
||||||
ref={bindIconAnimation}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={20}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</MantineAppShell.Navbar>
|
</MantineAppShell.Navbar>
|
||||||
|
|
||||||
<MantineAppShell.Main className={classes.main}>
|
<MantineAppShell.Main className={classes.main}>
|
||||||
<div className={classes.content}>
|
<div className={classes.content}>
|
||||||
{contentAboveOutlet}
|
{contentAboveOutlet}
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</MantineAppShell.Main>
|
</MantineAppShell.Main>
|
||||||
|
|
||||||
<nav aria-label={t("appName")} className={classes.bottomNav}>
|
<nav aria-label={t("appName")} className={classes.bottomNav}>
|
||||||
{items.map(item => (
|
{items.map((item) => {
|
||||||
<RouterNavLink
|
const appearanceClass = pathname.startsWith(item.to)
|
||||||
className={
|
? classes.active
|
||||||
(pathname.startsWith(item.to)) ? (classes.active) : undefined
|
: undefined;
|
||||||
}
|
|
||||||
key={item.to}
|
return (
|
||||||
to={item.to}
|
<RouterNavLink
|
||||||
>
|
className={appearanceClass}
|
||||||
{item.label}
|
key={item.to}
|
||||||
</RouterNavLink>
|
to={item.to}
|
||||||
))}
|
>
|
||||||
|
{item.label}
|
||||||
|
</RouterNavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</MantineAppShell>
|
</MantineAppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "./queries";
|
} from "./queries";
|
||||||
|
|
||||||
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
const card: BankCard
|
||||||
|
ksv741
commented
слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило
|
|||||||
{
|
= {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
balance: { currency: "RUB", value: "100" },
|
balance: { currency: "RUB", value: "100" },
|
||||||
bankName: null,
|
bankName: null,
|
||||||
@@ -31,8 +31,8 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
|
|||||||
openingDate: "2026-09-01",
|
openingDate: "2026-09-01",
|
||||||
paymentSystem: null,
|
paymentSystem: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
}
|
};
|
||||||
/* eslint-enable unicorn/no-null */);
|
|
||||||
const transfer: CreateBankOperation = {
|
const transfer: CreateBankOperation = {
|
||||||
amount: { currency: "RUB", value: "90071992547409.93" },
|
amount: { currency: "RUB", value: "90071992547409.93" },
|
||||||
cardId: "a",
|
cardId: "a",
|
||||||
@@ -55,6 +55,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
|||||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||||
const key = new URL(request.url).pathname + new URL(request.url).search;
|
const key = new URL(request.url).pathname + new URL(request.url).search;
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
|
||||||
return HttpResponse.json([card]);
|
return HttpResponse.json([card]);
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
||||||
@@ -84,7 +85,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
|||||||
page: 1,
|
page: 1,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
|
|
||||||
|
return url.pathname.includes("/operations/")
|
||||||
|
? HttpResponse.json({ ...transfer, version: 1 })
|
||||||
|
: HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -94,15 +98,18 @@ function useReadModels() {
|
|||||||
const a = useBankCard("a");
|
const a = useBankCard("a");
|
||||||
const b = useBankCard("b");
|
const b = useBankCard("b");
|
||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
|
|
||||||
const history = useBankOperations("a", {
|
const history = useBankOperations("a", {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
month: "2026-09",
|
month: "2026-09",
|
||||||
page: 2,
|
page: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenses = useBankExpenses("a", { page: 1 });
|
const expenses = useBankExpenses("a", { page: 1 });
|
||||||
const september = useBankSummary("2026-09");
|
const september = useBankSummary("2026-09");
|
||||||
const august = useBankSummary("2026-08", "b");
|
const august = useBankSummary("2026-08", "b");
|
||||||
const original = useBankOperation("original");
|
const original = useBankOperation("original");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutations: useBankMutations(),
|
mutations: useBankMutations(),
|
||||||
reads: [
|
reads: [
|
||||||
@@ -134,6 +141,7 @@ describe("banking API", () => {
|
|||||||
expect(url.searchParams.get("month")).toBe("2026-09");
|
expect(url.searchParams.get("month")).toBe("2026-09");
|
||||||
if (isFail && page === 2)
|
if (isFail && page === 2)
|
||||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
hasNext: page === 1,
|
hasNext: page === 1,
|
||||||
items: [{ ...transfer, id: String(page) }],
|
items: [{ ...transfer, id: String(page) }],
|
||||||
@@ -142,10 +150,12 @@ describe("banking API", () => {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
expect(pages).toEqual([1, 2]);
|
expect(pages).toEqual([1, 2]);
|
||||||
expect(result.current.data?.items.map(item => item.id)).toEqual([
|
expect(result.current.data?.items.map(item => item.id)).toEqual([
|
||||||
@@ -153,10 +163,12 @@ describe("banking API", () => {
|
|||||||
"2",
|
"2",
|
||||||
]);
|
]);
|
||||||
isFail = true;
|
isFail = true;
|
||||||
|
|
||||||
const { result: failedResult } = renderHook(
|
const { result: failedResult } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
||||||
expect(failedResult.current.data).toBeUndefined();
|
expect(failedResult.current.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -164,15 +176,15 @@ describe("banking API", () => {
|
|||||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
server.use(...readHandlers(counts));
|
server.use(...readHandlers(counts));
|
||||||
|
|
||||||
const { result } = renderHook(useReadModels, {
|
const { result } = renderHook(useReadModels, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||||
);
|
);
|
||||||
expect(counts.keys()).toContain(
|
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
|
||||||
"/api/v1/bank-cards?includeArchived=true",
|
|
||||||
);
|
|
||||||
expect(counts.keys()).toContain(
|
expect(counts.keys()).toContain(
|
||||||
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
||||||
);
|
);
|
||||||
@@ -184,10 +196,13 @@ describe("banking API", () => {
|
|||||||
|
|
||||||
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
||||||
let token = "expired";
|
let token = "expired";
|
||||||
|
|
||||||
const refresh = vi.fn(() => {
|
const refresh = vi.fn(() => {
|
||||||
token = "renewed";
|
token = "renewed";
|
||||||
|
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
||||||
const bodies: unknown[] = [];
|
const bodies: unknown[] = [];
|
||||||
const headers: (null | string)[] = [];
|
const headers: (null | string)[] = [];
|
||||||
@@ -195,14 +210,17 @@ describe("banking API", () => {
|
|||||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||||
bodies.push(await request.json());
|
bodies.push(await request.json());
|
||||||
headers.push(request.headers.get("Authorization"));
|
headers.push(request.headers.get("Authorization"));
|
||||||
|
|
||||||
return bodies.length === 1
|
return bodies.length === 1
|
||||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||||
: HttpResponse.json({ ...transfer, version: 1 });
|
: HttpResponse.json({ ...transfer, version: 1 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useBankMutations, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||||
expect(bodies).toEqual([transfer, transfer]);
|
expect(bodies).toEqual([transfer, transfer]);
|
||||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||||
@@ -214,14 +232,17 @@ describe("banking API", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||||
bodies.push(await request.json());
|
bodies.push(await request.json());
|
||||||
|
|
||||||
return bodies.length === 1
|
return bodies.length === 1
|
||||||
? HttpResponse.error()
|
? HttpResponse.error()
|
||||||
: HttpResponse.json({ ...transfer, version: 1 });
|
: HttpResponse.json({ ...transfer, version: 1 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useBankMutations, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
result.current.createOperation.mutateAsync(transfer),
|
result.current.createOperation.mutateAsync(transfer),
|
||||||
@@ -239,13 +260,16 @@ describe("banking API", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/bank-cards/operations", async () => {
|
http.post("*/api/v1/bank-cards/operations", async () => {
|
||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||||
let saving: Promise<unknown>;
|
let saving: Promise<unknown>;
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -310,10 +334,12 @@ describe("banking API", () => {
|
|||||||
HttpResponse.json({ code }, { status: 409 }),
|
HttpResponse.json({ code }, { status: 409 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -337,6 +363,7 @@ describe("banking API", () => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
requests.push(body);
|
requests.push(body);
|
||||||
name = "Groceries";
|
name = "Groceries";
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
id: "food",
|
id: "food",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@@ -348,14 +375,17 @@ describe("banking API", () => {
|
|||||||
"*/api/v1/bank-cards/operations/original",
|
"*/api/v1/bank-cards/operations/original",
|
||||||
async ({ request }) => {
|
async ({ request }) => {
|
||||||
requests.push(await request.json());
|
requests.push(await request.json());
|
||||||
|
|
||||||
return HttpResponse.json({ ...transfer, version: 2 });
|
return HttpResponse.json({ ...transfer, version: 2 });
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
||||||
requests.push(new URL(request.url).searchParams.get("version"));
|
requests.push(new URL(request.url).searchParams.get("version"));
|
||||||
|
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({
|
() => ({
|
||||||
categories: useBankCategories(),
|
categories: useBankCategories(),
|
||||||
@@ -363,6 +393,7 @@ describe("banking API", () => {
|
|||||||
}),
|
}),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||||
);
|
);
|
||||||
@@ -406,9 +437,11 @@ describe("banking API", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { queryClient, wrapper } = createQueryWrapper();
|
const { queryClient, wrapper } = createQueryWrapper();
|
||||||
|
|
||||||
const { rerender, result } = renderHook(() => useBankCard("a"), {
|
const { rerender, result } = renderHook(() => useBankCard("a"), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
||||||
await apiClient.PUT("/api/v1/tbank-connection", {
|
await apiClient.PUT("/api/v1/tbank-connection", {
|
||||||
body: { token: "synthetic" },
|
body: { token: "synthetic" },
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
|
enum BankName {
|
||||||
|
AlfaBank = "Альфа-Банк",
|
||||||
|
Sberbank = "Сбербанк",
|
||||||
|
TBank = "Т-Банк",
|
||||||
|
Vtb = "ВТБ",
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BankName };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
|
||||||
|
import type { ExpenseCategory } from "../api/queries";
|
||||||
|
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||||
|
return z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(
|
||||||
|
value => value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
||||||
|
{ error: validationMessage("bank.validation.category") },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
value =>
|
||||||
|
!categories?.some(
|
||||||
|
c =>
|
||||||
|
c.id !== editingId
|
||||||
|
&& c.name.toLocaleLowerCase("ru-RU")
|
||||||
|
=== value.toLocaleLowerCase("ru-RU"),
|
||||||
|
),
|
||||||
|
{ error: validationMessage("bank.validation.duplicate") },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { categorySchema };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user
можно ли упростить ? без использования String.raw ?