Compare commits

...
11 Commits
Author SHA1 Message Date
ksv741 a4046dbe87 refactor: address frontend review feedback
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
2026-09-28 19:20:32 +03:00
ksv741 ca0ad4f824 refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s
2026-09-28 18:48:23 +03:00
ksv741 5b69003516 refactor: enforce enum machine codes #18
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 9m31s
Use owner enums for known codes; preserve wire values and unknown fallbacks.

Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
2026-09-28 16:36:40 +03:00
ksv741 12c3b72bfb style: pin ternary operator formatting #18 2026-09-28 16:36:40 +03:00
ksv741 061ec708c6 chore: guard unsafe DOM HTML with ESLint #18
Enable no-unsanitized recommended rules and verify TypeScript DOM sink diagnostics.
2026-09-28 16:36:40 +03:00
ksv741 72d7835227 chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
2026-09-28 16:36:40 +03:00
ksv741 57630102e4 chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
2026-09-28 16:36:40 +03:00
ksv741 95fdc8759e ci: reduce duplicate actions and serialize checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m2s
2026-09-28 14:18:16 +03:00
codex-bot c1b08735e3 refactor: simplify resource queries and layout tests
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 5m51s
2026-09-27 18:53:12 +03:00
codex-bot 5e076470aa fix: resolve frontend audit findings (#39)
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m27s
2026-09-27 12:11:44 +03:00
ksv741 f6db28defa fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
2026-09-27 11:38:20 +03:00
362 changed files with 21269 additions and 10211 deletions
+5 -4
View File
@@ -69,10 +69,11 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui`
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай `ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md). [UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
`packages/icons`; package не зависит от UI, domain или motion. Приложение импортирует компоненты из `@wealthledger/icons` и подключает
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package `@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов,
Add/Edit/Delete; restore/category SVG остаются локальными. единственная задача которых — рисовать иконку. Domain mapping категории в
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
## Примеры ## Примеры
+1 -1
View File
@@ -70,7 +70,7 @@ Network/unknown error получает безопасное локальное
Если одна группа API codes требует одинакового recovery в нескольких views, Если одна группа API codes требует одинакового recovery в нескольких views,
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
и не объявляй закрытый frontend error enum: generated `code` пока `string`. и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`.
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
разных recovery paths требует behavior proof, а не общей обработки всех ошибок. разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
+2 -1
View File
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную. Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit. обычные labels не выноси в constants без semantic benefit.
+3 -1
View File
@@ -19,7 +19,9 @@ StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions. Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions.
Общие Mantine/theme/i18n/Dates providers уже есть в Общие Mantine/theme/i18n/Dates providers уже есть в
[preview](../../.storybook/preview.tsx); не создавай второй theme source. [preview](../../.storybook/preview.tsx); не создавай второй theme source.
Stories не достигают application API, auth, VM и query providers даже транзитивно; Stories не достигают application API, auth и query providers даже транзитивно.
Presentation VM с form-context/UI-only state допустима; data VM остаётся за
этой границей, которую проверяет полный reachable graph;
границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога. границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога.
`play` через `storybook/test` нужен, когда interaction иллюстрирует собственный `play` через `storybook/test` нужен, когда interaction иллюстрирует собственный
+42 -10
View File
@@ -5,7 +5,8 @@
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй - Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение `unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
на нетипизированной boundary должно быть узким и объяснённым. на нетипизированной boundary должно быть узким и объяснённым.
- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`. - Строковые `enum` используются для machine codes и protocol values.
`namespace` не используется; tooling сохраняет `erasableSyntaxOnly`.
Для состояния с разными payload выбирай discriminated union; не добавляй Для состояния с разными payload выбирай discriminated union; не добавляй
необязательные поля, допускающие невозможные комбинации. необязательные поля, допускающие невозможные комбинации.
- Named components — const-valued `FC<Props>`; named props находятся рядом или - Named components — const-valued `FC<Props>`; named props находятся рядом или
@@ -14,7 +15,34 @@
на основе `PropsWithChildren<{ … }>`; без props — `FC`. на основе `PropsWithChildren<{ … }>`; без props — `FC`.
- Обычные exports — нижний named block; barrel делает прямой re-export. - Обычные exports — нижний named block; barrel делает прямой re-export.
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [Biome](../../biome.json). Не распространяй их на 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
@@ -33,19 +61,23 @@ Container/VM/view и state owners — в [UI](ui.md).
Общее правило ownership и символических значений — Общее правило ownership и символических значений —
[Domain values](engineering.md#domain-values). [Domain values](engineering.md#domain-values).
Backend vocabulary бери из generated `paths`, не создавай ручную копию enum. Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй.
Текущий `schema.d.ts` содержит только types: typed wire literals допустимы, Для открытых `string` полей допускается локальный enum известных значений,
когда generator не предоставляет runtime values. используемых UI. Он не закрывает wire contract и сохраняет unknown fallback.
Если generator предоставляет runtime enum, используй его.
`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error `ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract.
enum и не превращай неизвестный code в известный через cast. Повторяющуюся `apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
обработку своди у ответственного owner; [errors](data.md#errors). обработку своди у ответственного owner; [errors](data.md#errors).
`noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только `@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
из этого правила. Policy получает имя и единицы; подавление допустимо для из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
incidental value с объяснением. Обычный UI-текст не превращай в constant: incidental value с объяснением. Обычный UI-текст не превращай в constant:
его owner — [i18n](ui.md#строки-и-валидация). его owner — [i18n](ui.md#строки-и-валидация).
Semantic magic strings и props profile требуют review. `no-restricted-syntax` запрещает inline machine codes и protocol values в production;
`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют
literal wire values. Новые vocabularies и props profile требуют review.
## Проверка ## Проверка
+44 -12
View File
@@ -11,13 +11,17 @@ Mantine component и общую тему. Для выпадающего выбо
ограничения JSDOM решай в test setup, сохраняя production UI. ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным. Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например, Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx)); lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны иконки из model сущности. Каждый SVG хранится цельным декларативным
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает компонентом: не разбивай path-строки и не собирай геометрию в runtime.
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings Русские category names из API — данные для нормализованного сопоставления,
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain не ключи реестра иконок; неизвестные/переименованные категории имеют fallback.
components живут у feature/entity/page, даже при повторном использовании. Известные названия банков принадлежат enum `BankName` в bank-card model;
свободный ввод пользовательского названия остаётся строкой.
SVG декоративны по умолчанию; доступное имя action
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
и доступное имя. Приложение и Storybook подключают CSS пакета.
Wrapper нужен для проектной ответственности, а не ради нового имени Wrapper нужен для проектной ответственности, а не ради нового имени
для Button. Локальный CSS module принадлежит component; общий token вводи для для Button. Локальный CSS module принадлежит component; общий token вводи для
@@ -51,10 +55,34 @@ retry callbacks, domain grouping и derived presentation data. View получа
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен | | `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
| `Component.module.css` | Локальные стили | | `Component.module.css` | Локальные стили |
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection. View получает готовые данные и именованные действия. Container вызывает VM;
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed VM владеет запросами, формой, состоянием, обработчиками и derived values.
image fallback) и formatter одного переданного значения. Группировка/доли Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу. вызывает container; presentation hook не должен скрывать fetching во view.
Presentation VM для form-context/UI-only state может использоваться автономно
в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view.
JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
Внутри компонента нет именованных helpers. Чистые функции форматирования и
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
VM собирает связные предметные hooks, когда внутри смешиваются независимые
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook
возвращает данные и действия своей ответственности, VM объединяет их для view.
Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления.
Render-helper замени самостоятельным presentation component у того же owner.
Отдельный state/section с самостоятельной responsibility выноси в Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй `components/` своего component. Небольшой tightly-coupled renderer оставляй
@@ -87,6 +115,10 @@ image fallback) и formatter одного переданного значени
## Формы ## Формы
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`. form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
Во время submit блокируй все действия формы. Zod errors показывай у поля; Во время submit блокируй все действия формы. Zod errors показывай у поля;
@@ -121,7 +153,7 @@ Types, props, hooks и memoization — в [TypeScript/React](typescript-react.md
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без `useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
пользовательских literal messages. Это mandatory semantic review: общего запрета пользовательских literal messages. Это mandatory semantic review: общего запрета
непереведённых JSX-строк в Biome/TypeScript нет. Synthetic story fixtures и непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и
описания историй допустимы; они не заменяют продуктовую локализацию. описания историй допустимы; они не заменяют продуктовую локализацию.
## Проверка ## Проверка
+10 -7
View File
@@ -22,13 +22,7 @@
!packages/ui/vite.config.ts !packages/ui/vite.config.ts
!packages/ui/tsconfig.json !packages/ui/tsconfig.json
!packages/ui/src/ !packages/ui/src/
!packages/ui/src/theme.ts !packages/ui/src/**
!packages/ui/src/styles.css
!packages/ui/src/index.ts
!packages/ui/src/StateView.tsx
!packages/ui/src/LoadingSkeleton.tsx
!packages/ui/src/EntryCard.tsx
!packages/ui/src/EntryCard.module.css
!packages/icons/ !packages/icons/
!packages/icons/package.json !packages/icons/package.json
!packages/icons/tsconfig.json !packages/icons/tsconfig.json
@@ -40,3 +34,12 @@
!packages/icons/src/svg/AddIcon.tsx !packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx !packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx !packages/icons/src/svg/DeleteIcon.tsx
# Keep local dependencies, outputs and secrets out of allowed source trees.
**/node_modules
**/dist
**/coverage
**/storybook-static
**/.env*
**/*.pem
**/*.key
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+2
View File
@@ -30,6 +30,8 @@ jobs:
- run: npm ci - run: npm ci
env: env:
HUSKY: '0' HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- run: npm run verify - run: npm run verify
env: env:
KNIP_DISABLE_RAW_TRANSFER: '1' KNIP_DISABLE_RAW_TRANSFER: '1'
-33
View File
@@ -1,33 +0,0 @@
name: Frontend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+66 -2
View File
@@ -2,12 +2,74 @@ name: Frontend verify
on: on:
push: push:
branches: branches: [develop, main]
- '**'
pull_request: pull_request:
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '17 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
verify: verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -21,6 +83,8 @@ jobs:
- run: npm ci - run: npm ci
env: env:
HUSKY: '0' HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- name: Run all frontend gates - name: Run all frontend gates
run: npm run verify run: npm run verify
env: env:
+1
View File
@@ -12,6 +12,7 @@ dist
storybook-static storybook-static
dist-ssr dist-ssr
coverage coverage
.vitest
.scannerwork .scannerwork
*.local *.local
+7 -2
View File
@@ -5,11 +5,16 @@ module.exports = {
forbidden: [ forbidden: [
...base.forbidden, ...base.forbidden,
{ {
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",
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
to: { to: {
path: "^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,
}, },
}, },
+2 -2
View File
@@ -1,10 +1,10 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"], addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true }, core: { disableTelemetry: true },
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
}; };
export default config; export default config;
+20 -16
View File
@@ -1,39 +1,34 @@
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 type { Preview } from "@storybook/react-vite";
import "../src/shared/i18n"; 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 type { Preview } from "@storybook/react-vite";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = { const colorSchemeManager = {
clear: () => {},
get: () => "light" as const, get: () => "light" as const,
set: () => {}, set: () => {},
clear: () => {},
subscribe: () => {}, subscribe: () => {},
unsubscribe: () => {}, unsubscribe: () => {},
}; };
const preview: Preview = { const preview: Preview = {
parameters: {
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
controls: { expanded: true },
},
decorators: [ decorators: [
(Story) => ( Story => (
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager} colorSchemeManager={colorSchemeManager}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme}
> >
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<Story /> <Story />
@@ -41,6 +36,15 @@ const preview: Preview = {
</MantineProvider> </MantineProvider>
), ),
], ],
parameters: {
controls: { expanded: true },
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
},
}; };
export default preview; export default preview;
+1 -1
View File
@@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice | | Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) | | Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения | | Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
| Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs | | Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs |
+62 -14
View File
@@ -75,20 +75,68 @@ Service worker кеширует только статическую оболоч
```bash ```bash
nvm use --silent nvm use --silent
npm ci
npx --no-install playwright install chromium
npm run verify npm run verify
``` ```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, затем запускает strict TypeScript, ESLint и проверку его конфигурации,
один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook. Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет `npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix`
применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
наборы всех установленных плагинов: TypeScript, React, Hooks, Refresh, Query,
Unicorn, import-x, SonarJS, RegExp, Security, no-unsanitized, Perfectionist, Stylistic,
Vitest, Testing Library, jest-dom и Storybook. Правила React, тестов и stories
применяются к соответствующим файлам; generated/build artifacts исключены.
Stylistic отвечает за форматирование JS/TS/TSX, Perfectionist — за сортировку.
Сохраняются табы, двойные кавычки и точки с запятой. Biome удалён; Prettier
не используется. CSS, JSON и Markdown не форматируются через ESLint.
`lint-staged` исправляет JS/TS/TSX перед commit. `lint:eslint` сначала собирает
private packages, чтобы анализ типов использовал актуальные declarations.
`npm run test:lint` проверяет действующие presets и ошибочные примеры.
`no-unsanitized` проверяет опасные DOM HTML-присваивания и вызовы;
React `dangerouslySetInnerHTML` отдельно проверяется React-плагином.
Точечные исключения в исходниках объясняют контракты и ограничения статического
анализа; изменение nullable API fields, query keys или порядка race-операций
ради линтера недопустимо. Архитектуру проверяет dependency-cruiser, поведение —
Vitest/MSW и Chromium. Линтеры не заменяют semantic/security review.
`jsx-a11y` без поддержки ESLint 10 остаётся отложенным в
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
и его заглушки layout API. Только браузерная проверка:
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды: исключений frontend нет. Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и `gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry; `npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения. проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Frontend verify / commits (pull_request)`,
`Frontend verify / security (pull_request)` и
`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`. TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
@@ -214,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`.
+70
View File
@@ -0,0 +1,70 @@
# Security-плагины ESLint
Проверено 28 сентября 2026 года для frontend: ESLint 10.11.0,
TypeScript и React 19. Рекомендация — добавить только
`eslint-plugin-no-unsanitized@4.1.5` с полным `configs.recommended`.
Решение реализовано: пакет добавлен в devDependencies, полный recommended
подключён в ESLint. Ниже сохранены результаты исследования до установки.
## Все пакеты из раздела Security
Источник списка: [awesome-eslint / Security](https://github.com/dustinspecker/awesome-eslint#security).
Версии и даты ниже — опубликованные `latest` из npm на момент проверки.
Широкий peer range сам по себе не доказывает работоспособность.
| Пакет | Версия; публикация | Заявленный peer ESLint | Решение для frontend |
| --- | --- | --- | --- |
| [no-unsanitized](https://github.com/mozilla/eslint-plugin-no-unsanitized) | [4.1.5](https://registry.npmjs.org/eslint-plugin-no-unsanitized/4.1.5); 2026-02-19 | `^9 \|\| ^10` | Добавить: проверяет опасные присваивания и вызовы native DOM HTML API. Flat recommended включает `property` и `method` как errors. |
| [no-secrets](https://github.com/nickdeis/eslint-plugin-no-secrets) | [2.3.3](https://registry.npmjs.org/eslint-plugin-no-secrets/2.3.3); 2026-03-01 | `>=5` | Не добавлять: поиск секретов по entropy/patterns существенно пересекается с существующими Gitleaks и SonarJS. Flat config поддерживается; отсутствие рекомендации связано с дублированием. |
| [pii](https://github.com/shiva-hack/eslint-plugin-pii) | [1.0.2](https://registry.npmjs.org/eslint-plugin-pii/1.0.2); 2021-04-13 | `>=4.19.1` | Не добавлять: правило падает на ESLint 10. Проверяет email/даты рождения/IP/телефоны в литералах и комментариях, а не потоки персональных данных; потребует исключений для тестовых данных. |
| [pg](https://github.com/interlace-collie/eslint/tree/main/packages/eslint-plugin-pg) | [1.4.14](https://registry.npmjs.org/eslint-plugin-pg/1.4.14); 2026-08-06 | `^8.0.0 \|\| ^9.0.0 \|\| ^10.0.0` | Не добавлять: предназначен для Node.js с `pg`, которого во frontend нет. Пакет deprecated и переименован в `eslint-plugin-postgresql-security`; старое имя больше не обновляется. |
| [security](https://github.com/eslint-community/eslint-plugin-security) | [4.1.0](https://registry.npmjs.org/eslint-plugin-security/4.1.0); 2026-09-27 | Не указан | Уже установлен с recommended. Ориентирован главным образом на Node.js; не заменяет проверки DOM. |
| [xss](https://github.com/Rantanen/eslint-plugin-xss) | [0.1.12](https://registry.npmjs.org/eslint-plugin-xss/0.1.12); 2022-06-27 | Не указан | Не добавлять: правило падает на ESLint 10. Проверяет HTML-конкатенации/соглашения об именах и присваивания `location.href`; это не специализированная проверка React/TSX. |
## Что добавляет no-unsanitized
[Правило property](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/property.md)
проверяет присваивания `innerHTML`/`outerHTML`;
[method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/method.md)
проверяет, в частности, `insertAdjacentHTML`, `document.write` и `document.writeln`.
В [исходнике method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/lib/rules/method.js)
также есть проверки `createContextualFragment`, `setHTMLUnsafe` и dynamic import.
Следовательно, перед включением нужно прогнать весь lint, а не только HTML-примеры.
До подключения no-unsanitized [eslint.config.mjs](eslint.config.mjs) уже включал
React-проверку `dom-no-dangerously-set-innerhtml` и security-правила SonarJS.
Предварительная проверка через локальный `ESLint.lintText` показала пробел:
функция с `element.innerHTML = html` не получала security-диагностику,
тогда как JSX `dangerouslySetInnerHTML` получал
диагностику React. Это различие в покрытии, не обнаруженная уязвимость приложения:
таких production DOM-присваиваний при поиске не найдено.
Плагин понимает TS-синтаксис через TypeScript parser, включая `as`-выражения,
но не использует TypeScript type checker. Он не доказывает безопасность всех
потоков данных. У версии 4.1.5 нет runtime dependencies; Prettier указан только
в devDependencies самого плагина и при его обычной установке потребителем
не устанавливается.
## Проверка совместимости без установки
Опубликованные npm tarballs прочитаны в `/tmp`; использованы локальные
ESLint 10.11.0 и `@typescript-eslint/parser`, без lifecycle-скриптов пакетов.
Через `Linter.verify` с recommended no-unsanitized проверены:
- `innerHTML`/`outerHTML` с параметром `html: string` — обе ошибки найдены;
- `insertAdjacentHTML`/`document.write`/`document.writeln` — все три ошибки найдены;
- `innerHTML = html as string` — ошибка найдена;
- безопасный `textContent` и обычная TSX-интерполяция — без ошибок.
Отдельный запуск реальных правил `pii/no-email` и
`xss/no-location-href-assign` завершился
`TypeError: context.getSourceCode is not a function` на ESLint 10.
Это подтверждает несовместимость проверенных версий, несмотря на широкий
или отсутствующий peer range. Обходы через legacy adapters не предлагаются.
Поиск секретов уже выполняют [security workflow](.gitea/workflows/security.yml)
и pre-commit Gitleaks; SonarJS дополнительно проверяет hardcoded secrets.
Добавление ещё одного secret-scanner в ESLint сейчас не оправдано.
Активацию обоих правил и примеры опасных DOM-вставок проверяет
`npm run test:lint`; интеграцию зависимости — полный `npm run verify`.
-80
View File
@@ -1,80 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!dist",
"!packages/*/dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
"!coverage",
"!node_modules"
]
},
"linter": {
"rules": {
"preset": "recommended",
"nursery": {
"useReactFunctionComponentDefinition": {
"level": "error",
"options": {
"namedComponents": "arrowFunction"
}
}
},
"style": {
"noDefaultExport": "error",
"useExportsLast": "error",
"noMagicNumbers": "error"
}
}
},
"overrides": [
{
"includes": [
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
".storybook/preview.tsx",
"packages/*/vite.config.ts"
],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
},
{
"includes": ["**/*.stories.tsx"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off",
"useExportsLast": "off"
}
}
}
},
{
"includes": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.stories.tsx",
"src/test/**"
],
"linter": {
"rules": {
"style": {
"noMagicNumbers": "off"
}
}
}
}
]
}
+22 -12
View File
@@ -1,63 +1,73 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{ {
from: {},
name: "no-unresolved",
severity: "error",
to: { couldNotResolve: true },
},
{
from: { path: "^packages/" },
name: "packages-must-not-depend-on-app", name: "packages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^packages/" },
to: { path: "^src/" }, to: { path: "^src/" },
}, },
{ {
from: {},
name: "no-circular", name: "no-circular",
severity: "error", severity: "error",
from: {},
to: { circular: true }, to: { circular: true },
}, },
{ {
name: "slices-must-use-public-api",
severity: "error",
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api",
severity: "error",
to: { to: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)", path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
}, },
{ {
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
from: { path: "^src/pages/" },
name: "pages-must-not-depend-on-app", name: "pages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" }, to: { path: "^src/app/" },
}, },
], ],
options: { options: {
exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" }, doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" }, enhancedResolveOptions: {
conditionNames: ["import", "require", "node", "default"],
exportsFields: ["exports"],
},
exclude: "^packages/[^/]+/dist/",
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
}, },
}; };
+178
View File
@@ -0,0 +1,178 @@
const readability = {
create(context) {
const filename = context.filename;
function checkParameters(node) {
const last = node.params.at(-1);
if (
last?.type === "Identifier"
&& !last.optional
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
&& last.typeAnnotation.typeAnnotation.types.some(
type => type.type === "TSUndefinedKeyword",
)
)
context.report({ messageId: "optional", node: last });
}
return {
ArrowFunctionExpression: checkParameters,
CallExpression(node) {
if (
node.callee.type === "MemberExpression"
&& ["refine", "superRefine"].includes(node.callee.property.name)
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
ImportDeclaration(node) {
if (
node.source.value === "zod"
&& node.importKind !== "type"
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
JSXOpeningElement(node) {
if (
!filename.includes("/packages/icons/")
&& node.name.type === "JSXIdentifier"
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
node.name.name,
)
)
context.report({ messageId: "svg", node });
},
TSUnionType(node) {
const complex
= node.types.length > 2
|| node.types.some(type => type.type === "TSTypeLiteral");
if (
complex
&& node.types.some(
(type, index) =>
index > 0
&& type.loc.start.line === node.types[index - 1].loc.end.line,
)
)
context.report({ messageId: "union", node });
},
VariableDeclarator(node) {
if (
!/^[A-Z]/.test(node.id.name ?? "")
|| node.init?.type !== "ArrowFunctionExpression"
|| node.init.body.type !== "BlockStatement"
)
return;
for (const statement of node.init.body.body) {
if (statement.type === "FunctionDeclaration")
context.report({ messageId: "helper", node: statement });
else if (statement.type === "VariableDeclaration") {
for (const declaration of statement.declarations) {
if (
["ArrowFunctionExpression", "FunctionExpression"].includes(
declaration.init?.type,
)
)
context.report({ messageId: "helper", node: declaration });
}
}
}
},
};
},
meta: {
messages: {
helper:
"Move helpers outside components; actions belong to the ViewModel.",
optional:
"Use an optional trailing parameter instead of a union with undefined.",
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.",
},
schema: [],
type: "suggestion",
},
};
function isComponent(node) {
const pending = [node];
while (pending.length > 0) {
const child = pending.pop();
if (!child) continue;
switch (child.type) {
case "JSXElement": {
return true;
}
case "JSXFragment": {
return true;
}
case "JSXExpressionContainer": {
pending.push(child.expression);
continue;
}
case "LogicalExpression": {
pending.push(child.right);
continue;
}
case "ConditionalExpression": {
pending.push(child.consequent, child.alternate);
continue;
}
}
}
return false;
}
const jsxSpacing = {
create(context) {
function check(node) {
let previous;
for (const child of node.children) {
if (child.type === "JSXText" && !child.value.trim()) continue;
if (isComponent(child)) {
if (previous) {
const gap = context.sourceCode.text.slice(
previous.range[1],
child.range[0],
);
if (!/\n\s*\n/.test(gap))
context.report({
fix: fixer => fixer.insertTextAfter(previous, "\n"),
messageId: "space",
node: child,
});
}
previous = child;
}
else previous = undefined;
}
}
return { JSXElement: check, JSXFragment: check };
},
meta: {
fixable: "whitespace",
messages: { space: "Separate sibling JSX components with a blank line." },
schema: [],
type: "layout",
},
};
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
+282
View File
@@ -0,0 +1,282 @@
import react from "@eslint-react/eslint-plugin";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
export default defineConfig(
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
plugins: { frontend },
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
},
globalIgnores([
"**/dist/**",
"dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]),
{
extends: [
js.configs.recommended,
importX.flatConfigs.recommended,
unicorn.configs.recommended,
sonarjs.configs.recommended,
regexp.configs.recommended,
security.configs.recommended,
noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"],
stylistic.configs.customize({
indent: "tab",
jsx: true,
quotes: "double",
semi: true,
}),
],
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": [
"error",
{
arrays: "always-multiline",
enums: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
generics: "ignore",
imports: "always-multiline",
objects: "always-multiline",
tuples: "always-multiline",
},
],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/max-len": [
"error",
{
code: 80,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
"@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"],
"@stylistic/padding-line-between-statements": [
"error",
{ blankLine: "always", next: "return", prev: "*" },
{
blankLine: "always",
next: "*",
prev: ["multiline-const", "multiline-let", "block-like", "type"],
},
{
blankLine: "always",
next: ["multiline-const", "multiline-let", "block-like", "type"],
prev: "*",
},
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
],
"no-nested-ternary": "error",
"perfectionist/sort-classes": [
"error",
{
groups: ["static-method", "property", "method", "unknown"],
type: "natural",
},
],
// Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
],
// React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": [
"error",
{ 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: {
"import-x/resolver-next": [
createTypeScriptImportResolver({ project: "tsconfig.json" }),
],
},
},
{
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
languageOptions: { globals: globals.node },
},
{
extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
{
extends: [
react.configs["recommended-type-checked"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
languageOptions: { globals: globals.browser },
rules: {
"import-x/exports-last": "error",
"import-x/no-default-export": "error",
},
},
{
extends: [refresh.configs.vite],
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
ignoreTypeIndexes: true,
},
],
},
},
{
extends: [vitest.configs.recommended],
files: ["**/*.test.{ts,tsx}"],
rules: {
"vitest/no-focused-tests": "error",
"vitest/valid-expect": ["error", { maxArgs: 2 }],
},
},
{
extends: [
testingLibrary.configs["flat/react"],
jestDom.configs["flat/recommended"],
],
files: ["**/*.test.tsx"],
},
storybook.configs["flat/recommended"],
{
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: {
"import-x/exports-last": "off",
"import-x/no-default-export": "off",
},
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [
"error",
{
message:
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
},
{
message: "Name the derived prop value before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
},
{
message:
"Use the owning enum member for machine codes and protocol values.",
selector:
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
},
{
message:
"Use 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 an inline code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message:
"Use an enum member instead of constructing a code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
},
{
message: "Use a computed enum member as the protocol mapping key.",
selector:
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
},
{
message:
"Compare codes with their enum members, not string literals.",
selector:
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
},
],
},
},
);
+3035 -174
View File
File diff suppressed because it is too large Load Diff
+30 -5
View File
@@ -13,25 +13,28 @@
"preview:catalog": "vite preview --outDir storybook-static", "preview:catalog": "vite preview --outDir storybook-static",
"typecheck": "npm run build:packages && tsc -b --noEmit", "typecheck": "npm run build:packages && tsc -b --noEmit",
"build": "npm run build:packages && tsc -b && vite build", "build": "npm run build:packages && tsc -b && vite build",
"lint": "biome check .", "lint": "npm run lint:eslint",
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
"test": "npm run build:packages && vitest", "test": "npm run build:packages && vitest",
"test:run": "npm run build:packages && vitest run", "test:run": "npm run build:packages && vitest run",
"test:coverage": "npm run build:packages && vitest run --coverage", "test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "node scripts/check-openapi.mjs", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky", "prepare": "husky",
"preview": "vite preview", "preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint", "commitlint": "commitlint",
"build:packages": "npm run build --workspaces --if-present" "build:packages": "npm run build --workspaces --if-present",
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
}, },
"lint-staged": { "lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
@@ -59,32 +62,54 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.5", "@babel/core": "8.0.5",
"@biomejs/biome": "2.5.13",
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.21.0",
"@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.0",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.13.4", "@types/node": "24.13.4",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.2.0",
"eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-storybook": "10.6.0",
"eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0",
"globals": "17.12.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.0.1", "jsdom": "30.0.1",
"knip": "6.35.1", "knip": "6.35.1",
"lint-staged": "17.5.1", "lint-staged": "17.5.1",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.63.0",
"release-it": "21.0.2", "release-it": "21.0.2",
"storybook": "10.6.0", "storybook": "10.6.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.70.1",
"vite": "8.3.0", "vite": "8.3.0",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.0"
+5 -2
View File
@@ -10,9 +10,12 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./styles.css": "./dist/index.css"
}, },
"sideEffects": false, "sideEffects": [
"**/*.css"
],
"scripts": { "scripts": {
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
+16 -10
View File
@@ -1,10 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon } from "./index";
const meta = { const meta = {
title: "Design System/Icons",
component: AddIcon, component: AddIcon,
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -13,21 +12,26 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Icons",
} 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 = {
render: () => ( render: () => (
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<button type="button" aria-label="Добавить пример"> <button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} /> <AddIcon size={20} strokeWidth={2} />
</button> </button>
<button type="button" aria-label="Изменить пример">
<button aria-label="Изменить пример" type="button">
<EditIcon size={18} /> <EditIcon size={18} />
</button> </button>
<button type="button" aria-label="Удалить пример">
<button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} /> <DeleteIcon size={18} />
</button> </button>
</div> </div>
@@ -36,10 +40,12 @@ export const Decorative: Story = {
export const Meaningful: Story = { export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} role="img" aria-label="Добавление" /> <AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />
<DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" /> <EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
}; };
+5 -2
View File
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
@@ -7,19 +8,21 @@ describe("icon accessibility", () => {
"keeps decoration hidden and exposes explicitly named images: %s", "keeps decoration hidden and exposes explicitly named images: %s",
(Icon) => { (Icon) => {
const { rerender } = render( const { rerender } = render(
<button type="button" aria-label="Действие"> <button aria-label="Действие" type="button">
<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.
screen.getByRole("button", { name: "Действие" }).querySelector("svg"), screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
).toHaveAttribute("focusable", "false"); ).toHaveAttribute("focusable", "false");
rerender( rerender(
<Icon <Icon
aria-hidden={false} aria-hidden={false}
role="img"
aria-label="Пример" aria-label="Пример"
role="img"
size={18} size={18}
strokeWidth={2} strokeWidth={2}
/>, />,
@@ -1,4 +1,5 @@
.icon { .icon {
flex-shrink: 0;
overflow: visible; overflow: visible;
& path { & path {
@@ -0,0 +1,72 @@
import type { FC } from "react";
import { BasketIcon } from "./svg/BasketIcon";
import { CarIcon } from "./svg/CarIcon";
import { CashWithdrawalIcon } from "./svg/CashWithdrawalIcon";
import { CoffeeIcon } from "./svg/CoffeeIcon";
import { DepositIcon } from "./svg/DepositIcon";
import { DiceIcon } from "./svg/DiceIcon";
import { DownloadIcon } from "./svg/DownloadIcon";
import { EducationIcon } from "./svg/EducationIcon";
import { GridIcon } from "./svg/GridIcon";
import { GrowthIcon } from "./svg/GrowthIcon";
import { HomeIcon } from "./svg/HomeIcon";
import { MedicalIcon } from "./svg/MedicalIcon";
import { MusicIcon } from "./svg/MusicIcon";
import { RefundIcon } from "./svg/RefundIcon";
import { RepaymentIcon } from "./svg/RepaymentIcon";
import { ServerIcon } from "./svg/ServerIcon";
import { ShieldIcon } from "./svg/ShieldIcon";
import { ShoppingIcon } from "./svg/ShoppingIcon";
import { SmokingIcon } from "./svg/SmokingIcon";
import { SubscriptionsIcon } from "./svg/SubscriptionsIcon";
import { TaxiIcon } from "./svg/TaxiIcon";
import { TopupIcon } from "./svg/TopupIcon";
import { TransferIcon } from "./svg/TransferIcon";
import { TravelIcon } from "./svg/TravelIcon";
import { WifiIcon } from "./svg/WifiIcon";
import { WineIcon } from "./svg/WineIcon";
const icons = {
basket: BasketIcon,
car: CarIcon,
cashWithdrawal: CashWithdrawalIcon,
coffee: CoffeeIcon,
deposit: DepositIcon,
dice: DiceIcon,
download: DownloadIcon,
education: EducationIcon,
grid: GridIcon,
growth: GrowthIcon,
home: HomeIcon,
medical: MedicalIcon,
music: MusicIcon,
refund: RefundIcon,
repayment: RepaymentIcon,
server: ServerIcon,
shield: ShieldIcon,
shopping: ShoppingIcon,
smoking: SmokingIcon,
subscriptions: SubscriptionsIcon,
taxi: TaxiIcon,
topup: TopupIcon,
transfer: TransferIcon,
travel: TravelIcon,
wifi: WifiIcon,
wine: WineIcon,
};
type OperationIconProps = {
color: string;
variant: keyof typeof icons;
};
const OperationIcon: FC<OperationIconProps> = (props) => {
const { color, variant } = props;
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed key of the static icon registry.
const Icon = icons[variant];
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
};
export { OperationIcon, type OperationIconProps };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const BasketIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.basket}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
<path d="M8 9l4-6 4 6" pathLength={1} />
<path d="M9 13v3" pathLength={1} />
<path d="M15 13v3" pathLength={1} />
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
</svg>
);
};
export { BasketIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CarIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.car}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
<path d="M8 16h8" pathLength={1} />
<path d="M6 11h12m-6-4v4" pathLength={1} />
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
</svg>
);
};
export { CarIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CashWithdrawalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10V3h16v7" pathLength={1} />
<path d="M7 7h10v14H7V7Z" pathLength={1} />
<path d="M7 11h10" pathLength={1} />
<path d="M10 16h4" pathLength={1} />
</svg>
);
};
export { CashWithdrawalIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CoffeeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.coffee}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M3 22h15" pathLength={1} />
</svg>
);
};
export { CoffeeIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DepositIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.deposit}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
</svg>
);
};
export { DepositIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DiceIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
<path d="M7 7h.01" pathLength={1} />
<path d="M17 7h.01" pathLength={1} />
<path d="M12 12h.01" pathLength={1} />
<path d="M7 17h.01" pathLength={1} />
<path d="M17 17h.01" pathLength={1} />
</svg>
);
};
export { DiceIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DownloadIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v13H3V3Z" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
<path d="M12 16v5" pathLength={1} />
<path d="M12 6v7" pathLength={1} />
<path d="M9 10l3 3 3-3" pathLength={1} />
</svg>
);
};
export { DownloadIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const EducationIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
<path d="M22 8v9" pathLength={1} />
</svg>
);
};
export { EducationIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GridIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.grid}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 4h6v6H4V4Z" pathLength={1} />
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
</svg>
);
};
export { GridIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GrowthIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.growth}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" pathLength={1} />
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
<path d="M16 5h5v5" pathLength={1} />
</svg>
);
};
export { GrowthIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const HomeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 11l9-8 9 8" pathLength={1} />
<path d="M5 10v11h14V10" pathLength={1} />
<path d="M9 21v-8h6v8" pathLength={1} />
</svg>
);
};
export { HomeIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MedicalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
</svg>
);
};
export { MedicalIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MusicIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 9l11-2" pathLength={1} />
</svg>
);
};
export { MusicIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RefundIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M8 4L3 9l5 5" pathLength={1} />
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
</svg>
);
};
export { RefundIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RepaymentIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.repayment}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Z" pathLength={1} />
<path d="M3 9h18" pathLength={1} />
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
</svg>
);
};
export { RepaymentIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ServerIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.server}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v7H3V3Z" pathLength={1} />
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
<path d="M7 6.5h.01" pathLength={1} />
<path d="M7 17.5h.01" pathLength={1} />
<path d="M15 6.5h3" pathLength={1} />
<path d="M15 17.5h3" pathLength={1} />
</svg>
);
};
export { ServerIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShieldIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
<path d="M8 12l3 3 5-6" pathLength={1} />
</svg>
);
};
export { ShieldIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShoppingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
</svg>
);
};
export { ShoppingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SmokingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
<path d="M16 14v5" pathLength={1} />
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
</svg>
);
};
export { SmokingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SubscriptionsIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
<path d="M20 3v4h-4" pathLength={1} />
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
<path d="M4 21v-4h4" pathLength={1} />
</svg>
);
};
export { SubscriptionsIcon };
@@ -0,0 +1,44 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TaxiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10l2-6h12l2 6" pathLength={1} />
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
<path d="M6 18v3" pathLength={1} />
<path d="M18 18v3" pathLength={1} />
<path d="M6 14h2" pathLength={1} />
<path d="M16 14h2" pathLength={1} />
<path d="M10 4V2h4v2" pathLength={1} />
</svg>
);
};
export { TaxiIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TopupIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.topup}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
</svg>
);
};
export { TopupIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TransferIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.transfer}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7h18l-4-4" pathLength={1} />
<path d="M21 17H3l4 4" pathLength={1} />
</svg>
);
};
export { TransferIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TravelIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
</svg>
);
};
export { TravelIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WifiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wifi}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
<path d="M12 19h.01" pathLength={1} />
</svg>
);
};
export { WifiIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WineIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wine}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
<path d="M12 13v8" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
</svg>
);
};
export { WineIcon };

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,36 +1,38 @@
import { fireEvent, render, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest"; import { afterEach, expect, it, vi } from "vitest";
import { bindIconAnimation } from "./bind-icon-animation"; import { bindIconAnimation } from "./bind-icon-animation";
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => { it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
vi.spyOn(window, "matchMedia").mockReturnValue({ vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true, matches: true,
} as MediaQueryList); } as MediaQueryList);
const { container, getByRole } = render(
const { container } = render(
<button type="button"> <button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" /> <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.
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 = 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(() => {
finished: new Promise<void>((resolve) => { const first = Promise.withResolvers<void>();
finishFirst = resolve; const last = Promise.withResolvers<void>();
}), finishFirst = first.resolve;
}, finishLast = last.resolve;
{
finished: new Promise<void>((resolve) => { return [{ finished: first.promise }, { finished: last.promise }];
finishLast = resolve; });
}),
},
]);
Object.defineProperty(icon, "getAnimations", { value: getAnimations }); Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target); fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target); fireEvent.mouseLeave(target);
@@ -50,18 +52,20 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
}); });
it("keeps playing after keyboard blur and releases cancelled animations", async () => { it("keeps playing after keyboard blur and releases cancelled animations", async () => {
const { container, getByRole, unmount } = render( const { container, unmount } = render(
<button type="button"> <button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" /> <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.
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 = getByRole("button"); const target = screen.getByRole("button");
// JSDOM does not model the browser keyboard-focus modality. // JSDOM does not model the browser keyboard-focus modality.
vi.spyOn(target, "matches").mockImplementation( vi.spyOn(target, "matches").mockImplementation(
(selector) => selector === ":focus-visible", selector => selector === ":focus-visible",
); );
let cancel!: () => void; let cancel!: () => void;
Object.defineProperty(icon, "getAnimations", { Object.defineProperty(icon, "getAnimations", {
@@ -1,67 +1,74 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon. // React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: SVGSVGElement | null) => { const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return; if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement; const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return; if (!target) return;
let hovered = false; let isHovered = false;
let focused = false; let isFocused = false;
let running = false; let isRunning = false;
let disposed = false; let isDisposed = false;
const release = () => { const release = () => {
if (!running && !hovered && !focused) { if (!isRunning && !isHovered && !isFocused) {
icon.removeAttribute("data-icon-animating"); delete icon.dataset.iconAnimating;
} }
}; };
const start = () => { const start = () => {
if (icon.hasAttribute("data-icon-animating")) return; if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.setAttribute("data-icon-animating", ""); icon.dataset.iconAnimating = "";
running = true; isRunning = true;
// Includes staggered/delayed children and finite iterations, without timers. // Includes staggered/delayed children and finite iterations, without timers.
const animations = icon.getAnimations({ subtree: true }); const animations = icon.getAnimations({ subtree: true });
void Promise.allSettled( void Promise.allSettled(
animations.map((animation) => animation.finished), animations.map(animation => animation.finished),
).then(() => { ).then(() => {
if (disposed) return; if (isDisposed) return;
running = false; isRunning = false;
release(); release();
}); });
}; };
const enter = () => { const enter = () => {
if (!window.matchMedia("(hover: hover)").matches) return; if (!matchMedia("(hover: hover)").matches) return;
hovered = true; isHovered = true;
start(); start();
}; };
const leave = () => { const leave = () => {
hovered = false; isHovered = false;
release(); release();
}; };
const focus = () => { const focus = () => {
focused = isFocused
target.matches(":focus-visible") || = target.matches(":focus-visible")
!!target.querySelector(":focus-visible"); || !!target.querySelector(":focus-visible");
if (focused) start(); if (isFocused) start();
}; };
const blur = (event: FocusEvent) => { const blur = (event: FocusEvent) => {
if ( if (
event.relatedTarget instanceof Node && event.relatedTarget instanceof Node
target.contains(event.relatedTarget) && target.contains(event.relatedTarget)
) )
return; return;
focused = 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 () => {
disposed = true; isDisposed = true;
target.removeEventListener("mouseenter", enter); target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave); target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus); target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur); target.removeEventListener("focusout", blur);
icon.removeAttribute("data-icon-animating"); delete icon.dataset.iconAnimating;
}; };
}; };
+13
View File
@@ -1,4 +1,17 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
export { AddIcon } from "./svg/AddIcon"; export { AddIcon } from "./svg/AddIcon";
export { BondsIcon } from "./svg/BondsIcon";
export { CardsIcon } from "./svg/CardsIcon";
export { CategoriesIcon } from "./svg/CategoriesIcon";
export { ContactlessIcon } from "./svg/ContactlessIcon";
export { DeleteIcon } from "./svg/DeleteIcon"; export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon"; export { EditIcon } from "./svg/EditIcon";
export { PortfolioIcon } from "./svg/PortfolioIcon";
export { RestoreIcon } from "./svg/RestoreIcon";
export { SettingsIcon } from "./svg/SettingsIcon";
export { StocksIcon } from "./svg/StocksIcon";
export { WalletIcon } from "./svg/WalletIcon";
export type { IconProps } from "./types"; export type { IconProps } from "./types";
+179
View File
@@ -0,0 +1,179 @@
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
+9 -7
View File
@@ -1,19 +1,21 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; 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
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true" aria-hidden="true"
fill="none"
focusable="false" focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M12 5v14 M5 12h14" /> <path d="M12 5v14 M5 12h14" />
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const BondsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</svg>
);
};
export { BondsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const CardsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</svg>
);
};
export { CardsIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const CategoriesIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
);
};
export { CategoriesIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const ContactlessIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
};
export { ContactlessIcon };
+8 -6
View File
@@ -1,20 +1,22 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; 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
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" /> <path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
+8 -6
View File
@@ -1,20 +1,22 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; 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
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" /> <path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
+43
View File
@@ -0,0 +1,43 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const PortfolioIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</svg>
);
};
export { PortfolioIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const RestoreIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
);
};
export { RestoreIcon };
+35
View File
@@ -0,0 +1,35 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const SettingsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</svg>
);
};
export { SettingsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const StocksIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</svg>
);
};
export { StocksIcon };
+29
View File
@@ -0,0 +1,29 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const WalletIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
);
};
export { WalletIcon };
+11 -3
View File
@@ -1,7 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": [
"ES2023",
"DOM"
],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true, "strict": true,
@@ -11,7 +14,12 @@
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"jsx": "react-jsx" "jsx": "react-jsx",
"types": [
"vite/client"
]
}, },
"include": ["src/index.ts"] "include": [
"src/index.ts"
]
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" }, lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] },
rolldownOptions: { external: /^react(\/|$)/ }, rolldownOptions: { external: /^react(\/|$)/ },
}, },
}); });
+1 -1
View File
@@ -131,7 +131,7 @@
}, },
"muted": { "muted": {
"color": "gray", "color": "gray",
"shade": 6, "shade": 7,
"description": "Вторичный текст; проверяйте контраст при выборе фона" "description": "Вторичный текст; проверяйте контраст при выборе фона"
}, },
"border": { "border": {
+17 -13
View File
@@ -1,27 +1,27 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
const meta = { const meta = {
title: "Design System/Components/EntryCard",
component: EntryCard,
tags: ["autodocs"],
args: { args: {
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
metadata: "22.09.2026 · Синтетические данные",
initial: "У",
imageSrc: "data:image/png;base64,invalid",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
tone: "negative",
actions: ( actions: (
<Button size="compact-sm" onClick={fn()}> <Button onClick={fn()} size="compact-sm">
Открыть пример Открыть пример
</Button> </Button>
), ),
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "22.09.2026 · Синтетические данные",
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
tone: "negative",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
}, },
component: EntryCard,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -30,8 +30,12 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Components/EntryCard",
} 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 = {};
+37 -8
View File
@@ -1,26 +1,45 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { assert, describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
describe("EntryCard", () => { describe("EntryCard", () => {
it("keeps the fallback after an image fails", () => { it("does not leak a numeric zero from an absent action", () => {
render(
<MantineProvider>
<EntryCard
actions={0}
initial="У"
metadata="Описание"
title="Пример"
tone="neutral"
value="—"
valueLabel="Неизвестно"
/>
</MantineProvider>,
);
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("keeps a failed image fallback until a new source is supplied", () => {
const props = { const props = {
title: "Пример", imageSrc: "data:image/png;base64,invalid",
metadata: "Описание",
initial: "У", initial: "У",
metadata: "Описание",
title: "Пример",
tone: "neutral" as const,
value: "—", value: "—",
valueLabel: "Неизвестно", valueLabel: "Неизвестно",
tone: "neutral" as const,
imageSrc: "data:image/png;base64,invalid",
}; };
const { container, 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 = container.querySelector("img"); const image = screen.queryByAltText("");
assert(image); assert(image);
fireEvent.error(image); fireEvent.error(image);
rerender( rerender(
@@ -28,8 +47,18 @@ describe("EntryCard", () => {
<EntryCard {...props} title="Обновлённый пример" /> <EntryCard {...props} title="Обновлённый пример" />
</MantineProvider>, </MantineProvider>,
); );
expect(container.querySelector("img")).toBeNull(); expect(screen.queryByAltText("")).not.toBeInTheDocument();
expect(screen.getByText("У")).toBeInTheDocument(); expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender(
<MantineProvider>
<EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>,
);
expect(screen.queryByAltText("")).toHaveAttribute(
"src",
"/replacement-logo.png",
);
expect(screen.queryByText("У")).not.toBeInTheDocument();
}); });
}); });
+32 -28
View File
@@ -1,66 +1,70 @@
import type { FC, ReactNode } from "react";
import { Avatar, Paper, Stack, Text } from "@mantine/core"; import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css"; import classes from "./EntryCard.module.css";
type EntryCardProps = { type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string; color?: string;
imageSrc?: string | null; details?: ReactNode;
icon?: ReactNode;
imageSrc?: null | string;
initial: string;
metadata: string;
title: string;
tone: "negative"
| "neutral"
| "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
tone: "positive" | "negative" | "neutral";
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false); const surfaceStyle = props.color
return ( ? {
<Paper background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
className={classes.row}
withBorder
style={
props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
: undefined
} }
> : 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"
radius="md" radius="md"
size={40} size={40}
src={imageFailed ? null : props.imageSrc} src={props.imageSrc}
onError={() => setImageFailed(true)}
> >
{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>
{props.actions && (
{Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div> <div className={classes.actions}>{props.actions}</div>
)} )}
</div> </div>
@@ -1,16 +1,16 @@
import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Paper, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "./LoadingSkeleton"; import { LoadingSkeleton } from "./LoadingSkeleton";
const meta = { const meta = {
title: "Design System/Components/LoadingSkeleton",
component: LoadingSkeleton,
tags: ["autodocs"],
args: { label: "Загрузка содержимого" }, args: { label: "Загрузка содержимого" },
argTypes: { argTypes: {
label: { control: "text" },
children: { table: { disable: true } }, children: { table: { disable: true } },
label: { control: "text" },
}, },
component: LoadingSkeleton,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -19,17 +19,23 @@ const meta = {
}, },
}, },
}, },
render: (args) => ( render: arguments_ => (
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...args}> <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>
), ),
tags: ["autodocs"],
title: "Design System/Components/LoadingSkeleton",
} 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: "Загрузка области" };
@@ -1,6 +1,7 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Stack } from "@mantine/core";
type LoadingSkeletonProps = PropsWithChildren<{ type LoadingSkeletonProps = PropsWithChildren<{
label: string; label: string;
}>; }>;
+31 -25
View File
@@ -1,36 +1,36 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { Button } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & { type StateStoryArguments = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void; onAction: () => void;
withAction: boolean;
}; };
const meta = { const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: { args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false, error: false,
withAction: false,
onAction: fn(), onAction: fn(),
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
title: "Нет учебных данных",
withAction: false,
}, },
argTypes: { argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } }, action: { table: { disable: true } },
error: { control: "boolean" },
onAction: { control: false },
text: { control: "text" },
title: { control: "text" },
withAction: { withAction: {
control: "boolean", control: "boolean",
description: "Показать необязательное действие StateView", description: "Показать необязательное действие StateView",
}, },
onAction: { control: false },
}, },
component: StateView,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -39,40 +39,46 @@ const meta = {
}, },
}, },
}, },
render: (args) => { render: (arguments_) => {
const { withAction, onAction, ...stateProps } = args; const { onAction, withAction, ...stateProps } = arguments_;
return ( return (
<StateView <StateView
{...stateProps} {...stateProps}
action={ action={
withAction ? ( withAction
<Button onClick={onAction} variant="default"> ? (
{args.error ? "Повторить локально" : "Выполнить пример"} <Button onClick={onAction} variant="default">
</Button> {arguments_.error ? "Повторить локально" : "Выполнить пример"}
) : undefined </Button>
)
: undefined
} }
/> />
); );
}, },
} satisfies Meta<StateStoryArgs>; tags: ["autodocs"],
title: "Design System/Components/StateView",
} satisfies Meta<StateStoryArguments>;
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 = {
name: "Пустое состояние с действием",
args: { withAction: true }, args: { withAction: true },
name: "Пустое состояние с действием",
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка",
args: { args: {
error: true, error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.", text: "Демонстрация недоступных данных без вызова API.",
title: "Учебная ошибка",
}, },
name: "Ошибка",
}; };
export const ErrorWithAction: Story = { export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true }, args: { ...Failed.args, withAction: true },
name: "Ошибка с retry",
}; };
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton"; import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
+8 -5
View File
@@ -1,22 +1,25 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
type StateViewProps = { type StateViewProps = {
title: string;
text: string;
action?: ReactNode; action?: ReactNode;
error?: boolean; error?: boolean;
text: string;
title: string;
}; };
const StateView: FC<StateViewProps> = (props) => { const StateView: FC<StateViewProps> = (props) => {
const { title, text, action, error = false } = props; const { action, error = false, text, title } = props;
const role = error ? "alert" : undefined; const role = error ? "alert" : undefined;
return ( return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}> <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>
+4
View File
@@ -1,6 +1,7 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core"; import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
import source from "@wealthledger/tokens/tokens.json"; import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme"; import { colorRoles, cssVariablesResolver, theme } from "./theme";
describe("design theme adapter", () => { describe("design theme adapter", () => {
@@ -25,5 +26,8 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength( expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length, Object.keys(source.color.roles).length,
); );
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
variables["--wl-color-muted"],
);
}); });
}); });
+33 -27
View File
@@ -1,8 +1,7 @@
import "./styles.css";
import { import {
type CSSVariablesResolver,
colorsTuple, colorsTuple,
createTheme, createTheme,
type CSSVariablesResolver,
getThemeColor, getThemeColor,
type MantinePrimaryShade, type MantinePrimaryShade,
Modal, Modal,
@@ -11,76 +10,83 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json"; import designTokens from "@wealthledger/tokens/tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } = import "./styles.css";
designTokens;
const { breakpoints, color, radius, shadows, spacing, typography }
= 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)]),
); );
const colorRoles = mapValues(color.roles, (role) => const colorRoles = mapValues(color.roles, role =>
role.shade === null ? role.color : `${role.color}.${role.shade}`, role.shade === null ? role.color : `${role.color}.${role.shade}`,
); );
const theme = createTheme({ const theme = createTheme({
respectReducedMotion: true, black: color.base.black,
breakpoints: mapValues(
breakpoints.values,
value => `${value}${breakpoints.unit}`,
),
colors: mapValues(color.palettes, colorsTuple),
components: { components: {
Modal: Modal.extend({ Modal: Modal.extend({
defaultProps: { defaultProps: {
overlayProps: { blur: 3 }, overlayProps: { blur: 3 },
transitionProps: { transitionProps: {
transition: "fade-down",
duration: 220, duration: 220,
exitDuration: 180, exitDuration: 180,
timingFunction: "ease-out", timingFunction: "ease-out",
transition: "fade-down",
}, },
}, },
}), }),
}, },
primaryColor: color.roles.primary.color,
primaryShade: color.primaryShade as MantinePrimaryShade,
defaultRadius: radius.default, defaultRadius: radius.default,
white: color.base.white,
black: color.base.black,
colors: mapValues(color.palettes, colorsTuple),
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontFamilyMonospace: typography.fontFamilyMonospace, fontFamilyMonospace: typography.fontFamilyMonospace,
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)), fontSizes: mapValues(typography.fontSizes, value => rem(value)),
fontWeights: mapValues(typography.fontWeights, String), fontWeights: mapValues(typography.fontWeights, String),
lineHeights: mapValues(typography.lineHeights, String),
headings: { headings: {
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontWeight: String(typography.fontWeights.bold), fontWeight: String(typography.fontWeights.bold),
sizes: mapValues(typography.headings, (value) => ({ sizes: mapValues(typography.headings, value => ({
fontSize: rem(value.fontSize), fontSize: rem(value.fontSize),
lineHeight: String(value.lineHeight), lineHeight: String(value.lineHeight),
})), })),
}, },
spacing: mapValues(spacing.values, (value) => rem(value)), lineHeights: mapValues(typography.lineHeights, String),
radius: mapValues(radius.values, (value) => rem(value)), primaryColor: color.roles.primary.color,
shadows: mapValues(shadows.values, (layers) => primaryShade: color.primaryShade as MantinePrimaryShade,
radius: mapValues(radius.values, value => rem(value)),
respectReducedMotion: true,
shadows: mapValues(shadows.values, layers =>
layers layers
.map( .map(
(layer) => layer =>
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`, `${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
) )
.join(", "), .join(", "),
), ),
breakpoints: mapValues( spacing: mapValues(spacing.values, value => rem(value)),
breakpoints.values, white: color.base.white,
(value) => `${value}${breakpoints.unit}`,
),
}); });
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({
dark: {},
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
variables: Object.fromEntries( variables: Object.fromEntries(
Object.entries(colorRoles).map(([role, reference]) => [ Object.entries(colorRoles).map(([role, reference]) => [
`--wl-color-${role}`, `--wl-color-${role}`,
getThemeColor(reference, resolvedTheme), getThemeColor(reference, resolvedTheme),
]), ]),
), ),
light: {},
dark: {},
}); });
export { colorRoles, cssVariablesResolver, designTokens, theme }; export { colorRoles, cssVariablesResolver, theme };
export { default as designTokens } from "@wealthledger/tokens/tokens.json";
+1 -1
View File
@@ -3,9 +3,9 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
cssFileName: "styles",
entry: { index: "src/index.ts", theme: "src/theme.ts" }, entry: { index: "src/index.ts", theme: "src/theme.ts" },
formats: ["es"], formats: ["es"],
cssFileName: "styles",
}, },
rolldownOptions: { rolldownOptions: {
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/, external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
+31 -18
View File
@@ -1,31 +1,37 @@
const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { const {
chmodSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
writeFileSync,
chmodSync,
rmSync, rmSync,
writeFileSync,
} = require("node:fs"); } = require("node:fs");
const { tmpdir } = require("node:os"); const { tmpdir } = require("node:os");
const { join, resolve } = require("node:path"); const nodePath = require("node:path");
const { test } = require("node:test");
const EXECUTABLE_MODE = 0o755; const EXECUTABLE_MODE = 0o755;
const { spawnSync } = require("node:child_process"); 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(join(tmpdir(), "wl-image-retry-")); const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync( const workflow = readFileSync(
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")[1] .split(
.split(" - name:")[0] " - name: Publish immutable release tag\n run: |\n",
.replace(/^ {10}/gm, ""); 2,
const double = `#!${process.execPath} )[1]
.split(" - name:", 1)[0]
.replaceAll(/^ {10}/gm, "");
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(' ');
fs.appendFileSync('calls', args + '\\n'); fs.appendFileSync('calls', args + '\n');
if (args.startsWith('manifest inspect')) { if (args.startsWith('manifest inspect')) {
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); } if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
} else if (args.startsWith('buildx imagetools inspect')) { } else if (args.startsWith('buildx imagetools inspect')) {
@@ -39,37 +45,44 @@ 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"]) {
writeFileSync(join(directory, name), double); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(join(directory, name), EXECUTABLE_MODE); writeFileSync(nodePath.join(directory, name), double);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
} }
const run = (extra = {}) => const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], { spawnSync("/bin/sh", ["-ec", block], {
cwd: directory, cwd: directory,
encoding: "utf8", encoding: "utf8",
env: { env: {
...process.env, ...process.env,
PATH: `${directory}:${process.env.PATH}`,
IMAGE: "synthetic/image", IMAGE: "synthetic/image",
PATH: `${directory}:${process.env.PATH}`,
RELEASE_TAG: "v1.2.3", RELEASE_TAG: "v1.2.3",
REVISION: "commit", REVISION: "commit",
...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);
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0); assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0); assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
const calls = readFileSync(join(directory, "calls"), "utf8"); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
assert.equal( assert.equal(
calls.split("\n").filter((line) => line.startsWith("buildx build")) calls.split("\n").filter(line => line.startsWith("buildx build"))
.length, .length,
1, 1,
); );
assert.ok(calls.includes("synthetic/image@sha256:exact")); assert.ok(calls.includes("synthetic/image@sha256:exact"));
} finally { }
rmSync(directory, { recursive: true, force: true }); finally {
rmSync(directory, { force: true, recursive: true });
} }
}); });
+16 -11
View File
@@ -1,14 +1,15 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import nodePath from "node:path";
const temporaryDirectory = await mkdtemp( const temporaryDirectory = await mkdtemp(
join(tmpdir(), "wealth-ledger-openapi-"), nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
); );
const generatedPath = join(temporaryDirectory, "schema.d.ts");
const committedPath = resolve("src/shared/api/generated/schema.d.ts"); const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js"); const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0; let exitCode = 0;
try { try {
@@ -18,14 +19,13 @@ try {
{ encoding: "utf8" }, { encoding: "utf8" },
); );
if (generated.status !== 0) { if (generated.status === 0) {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
} else {
const [committed, current] = await Promise.all([ const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"), readFile(committedPath, "utf8"),
// 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.",
@@ -33,8 +33,13 @@ try {
exitCode = 1; exitCode = 1;
} }
} }
} finally { else {
await rm(temporaryDirectory, { recursive: true, force: true }); process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
} }
process.exitCode = exitCode; process.exitCode = exitCode;
+9 -4
View File
@@ -1,14 +1,18 @@
const assert = require("node:assert/strict"); 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");
const git = (...args) => execFileSync("git", args, { 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 tags = git(
const tag_ = git(
"tag", "tag",
"--merged", "--merged",
"HEAD", "HEAD",
@@ -17,9 +21,10 @@ const tags = git(
"--sort=-version:refname", "--sort=-version:refname",
) )
.split("\n") .split("\n")
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); .find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal( assert.equal(
version, version,
tags[0]?.slice(1) ?? "0.0.0", tag_?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.", "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
); );
+340
View File
@@ -0,0 +1,340 @@
import react from "@eslint-react/eslint-plugin";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { ESLint } from "eslint";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict";
import { globSync } from "node:fs";
import { test } from "node:test";
import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint();
for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }",
"@typescript-eslint/no-floating-promises",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
"react-hooks/rules-of-hooks",
],
[
"src/shared/api/problem-details.ts",
"export const values = Array(3).fill({});",
"unicorn/no-array-fill-with-reference-type",
],
[
"src/shared/api/problem-details.ts",
"export default 1;",
"import-x/no-default-export",
],
[
"src/shared/api/problem-details.ts",
"new Error(\"forgot to throw\");",
"sonarjs/no-unthrown-error",
],
[
"src/shared/api/problem-details.ts",
"export const pattern = /[aab]/;",
"regexp/no-dupe-characters-character-class",
],
[
"src/shared/api/problem-details.ts",
"export type Status = \"z\" | \"a\";",
"perfectionist/sort-union-types",
],
[
"src/shared/api/problem-details.ts",
"//missing space\nexport const ready = true;",
"@stylistic/spaced-comment",
],
[
"src/shared/api/problem-details.ts",
"export function pattern(input: string) { return new RegExp(input); }",
"security/detect-non-literal-regexp",
],
[
"scripts/check-openapi.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.innerHTML = html; }",
"no-unsanitized/property",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.insertAdjacentHTML(\"beforeend\", html); }",
"no-unsanitized/method",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
"jest-dom/prefer-focus",
],
[
"src/shared/api/client.test.ts",
"import { it } from \"vitest\"; it.only(\"focused\", () => {});",
"vitest/no-focused-tests",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
"testing-library/await-async-queries",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
"@tanstack/query/stable-query-client",
],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, filePath);
assert.ok(
result.messages.some(message => message.ruleId === rule),
rule,
);
}
for (const file of [
"src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js",
"storybook-static/index.js",
"coverage/index.js",
])
assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx",
);
assert.equal(story.rules["import-x/no-default-export"][0], 0);
assert.ok(story.rules["storybook/default-exports"][0] > 0);
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
});
test("recommended plugin rules retain the explicit null-contract exception", async () => {
const eslint = new ESLint();
const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
[unicorn.configs.recommended, source],
[sonarjs.configs.recommended, source],
[regexp.configs.recommended, source],
[security.configs.recommended, source],
[noUnsanitized.configs.recommended, source],
[perfectionist.configs["recommended-natural"], source],
[stylistic.configs.recommended, component],
[react.configs["recommended-type-checked"], component],
[hooks.configs.flat.recommended, component],
[refresh.configs.vite, component],
[query.configs["flat/recommended"], component],
[vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"]
.filter(config => config.rules)
.map(config => [
config,
config.name.endsWith("main-rules")
? ".storybook/main.ts"
: "src/shared/ui/Button.stories.tsx",
]),
]) {
const configs = [preset].flat();
const expected = Object.assign(
{},
...configs.map(config => config.rules),
);
const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
if (rule === "unicorn/no-null") {
assert.equal(actual.get(rule)?.[0], 0);
continue;
}
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
}
}
});
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath },
);
assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output);
const [again] = await eslint.lintText(fixed.output, { filePath });
assert.equal(again.errorCount, 0);
assert.equal(again.output, undefined);
});
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
});
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
const eslint = new ESLint();
const filePath = "src/shared/api/problem-details.ts";
for (const code of [
"export function check(code: string) { return code === \"one-off-code\"; }",
"export const result = `MOEX_UNAVAILABLE`;",
"export const result = { code: \"one-off-code\" };",
"export const result = { \"errorCode\": \"one-off-code\" };",
"export const result = { code: `dynamic-${Date.now()}` };",
"export const result = \"MOEX_UNAVAILABLE\";",
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
code,
);
}
for (const code of [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"export const select = { code: true, errorCode: true };",
"export const label = \"Please try again\"; export const key = \"JWT_SECRET\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
code,
);
}
const [fixture] = await eslint.lintText(
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
{ filePath: "src/shared/api/client.test.ts" },
);
assert.ok(
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
);
const [comparison] = await eslint.lintText(
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
{ filePath },
);
assert.ok(
comparison.messages.some(
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
),
);
});
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);
});
+33 -15
View File
@@ -9,53 +9,71 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path"; import nodePath from "node:path";
import { test } from "node:test"; import { test } from "node:test";
const GITHUB_TOKEN_RANDOM_BYTES = 18; const GITHUB_TOKEN_RANDOM_BYTES = 18;
test("pre-commit scans the index, redacts findings and fails closed", () => { test("pre-commit scans the index, redacts findings and fails closed", () => {
const hook = resolve(".husky/pre-commit"); const hook = nodePath.resolve(".husky/pre-commit");
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-")); const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
const bin = join(cwd, "bin"); const bin = nodePath.join(cwd, "bin");
const marker = 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")}`;
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" });
const run = (path = `${bin}${delimiter}${process.env.PATH}`) => const git = (...arguments_) =>
execFileSync("git", arguments_, { cwd, stdio: "pipe" }); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], { spawnSync("/bin/sh", ["-e", hook], {
cwd, cwd,
env: { ...process.env, PATH: path },
encoding: "utf8", encoding: "utf8",
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.
mkdirSync(bin); mkdirSync(bin);
for (const command of ["npx", "npm"]) { for (const command of ["npx", "npm"]) {
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mode: 0o755, writeFileSync(
}); nodePath.join(bin, command),
"#!/bin/sh\necho ran >> downstream\n",
{
mode: 0o755,
},
);
} }
git("init", "--quiet"); git("init", "--quiet");
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n"); // 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");
git("add", "sample.txt"); git("add", "sample.txt");
// An unstaged secret must not be mistaken for the index being committed. // An unstaged secret must not be mistaken for the index being committed.
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`); // 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"), `token=${token}\n`);
let result = run(); let result = run();
assert.equal( assert.equal(
result.status, result.status,
0, 0,
"clean index must pass; install Gitleaks first", "clean index must pass; install Gitleaks first",
); );
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(existsSync(marker)); assert.ok(existsSync(marker));
rmSync(marker); rmSync(marker);
git("add", "sample.txt"); git("add", "sample.txt");
result = run(); result = run();
assert.equal(result.status, 1, "staged secret must block commit"); assert.equal(result.status, 1, "staged secret must block commit");
assert.ok(!`${result.stdout}${result.stderr}`.includes(token)); assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker), "formatting/dependency checks must not run"); assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
result = run(bin); result = run(bin);
assert.notEqual(result.status, 0, "missing Gitleaks must block commit"); assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker)); assert.ok(!existsSync(marker));
} finally { }
rmSync(cwd, { recursive: true, force: true }); finally {
rmSync(cwd, { force: true, recursive: true });
} }
}); });
+158 -119
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
@@ -7,105 +9,119 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import type { FC } from "react";
import { MemoryRouter, useLocation } from "react-router"; import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
const accounts = [ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
]; ];
const position: Portfolio["positions"][number] = {
figi: "SBER", const position: Portfolio["positions"][number]
displayName: "Сбербанк", = {
type: "STOCK", absoluteReturn: null,
quantity: "2", averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" }, currentValue: { currency: "RUB", value: "600" },
absoluteReturn: null, displayName: "Сбербанк",
averagePurchasePrice: null, figi: "SBER",
isin: null, isin: null,
ticker: "SBER", logoName: null,
logoName: null, quantity: "2",
}; ticker: "SBER",
type: "STOCK",
};
const portfolio: Portfolio = { const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-15T12:00:00Z",
isStale: false,
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [], cashBalances: [],
positions: accounts.map((account) => ({ ...position, account })), isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })),
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation = {
id: "same", const receipt: BrokerageOperation
occurredAt: "2026-08-15T12:00:00Z", = {
type: "OPERATION_TYPE_COUPON", accruedInterest: null,
category: "income", category: "income",
description: "Выплата купона", commission: null,
figi: null, description: "Выплата купона",
instrumentUid: null, figi: null,
ticker: "BOND", id: "same",
instrumentType: "bond", instrumentType: "bond",
logoName: null, instrumentUid: null,
payment: { currency: "RUB", value: "1" }, logoName: null,
quantity: null, occurredAt: "2026-08-15T12:00:00Z",
price: null, payment: { currency: "RUB", value: "1" },
accruedInterest: null, price: null,
commission: null, quantity: null,
}; ticker: "BOND",
const items = accounts.map((account) => ({ ...receipt, account })); type: "OPERATION_TYPE_COUPON",
const analytics: IncomeAnalytics = { };
accountId: "all",
period: { from: "2026-08-01", to: "2026-08-31" }, const items = accounts.map(account => ({ ...receipt, account }));
calculatedAt: "2026-09-01T12:00:00Z",
totalsByCurrency: [ const analytics: IncomeAnalytics
{ = {
currency: "RUB", accountId: "all",
coupon: { currency: "RUB", value: "2" }, bucketGranularity: "day",
dividend: { currency: "RUB", value: "0" }, buckets: [],
total: { currency: "RUB", value: "2" }, calculatedAt: "2026-09-01T12:00:00Z",
entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })),
instruments: [
{
coupon: { currency: "RUB", value: "2" },
displayName: "Облигация",
dividend: { currency: "RUB", value: "0" },
figi: null,
instrumentType: "bond",
instrumentUid: null,
key: "bond:RUB",
receiptCount: 2,
ticker: "BOND",
total: { currency: "RUB", value: "2" },
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
}, },
], totalsByCurrency: [
previous: { {
period: { from: "2026-07-01", to: "2026-07-31" }, coupon: { currency: "RUB", value: "2" },
totalsByCurrency: [], currency: "RUB",
}, dividend: { currency: "RUB", value: "0" },
yearComparison: null, total: { currency: "RUB", value: "2" },
bucketGranularity: "day", },
buckets: [], ],
instruments: [ yearComparison: null,
{ };
key: "bond:RUB",
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "Облигация",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
receiptCount: 2,
},
],
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
};
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(
@@ -114,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;
} }
@@ -128,18 +146,22 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test", accessToken: "test",
user: { id: "user", email: "user@example.com" }, user: { email: "user@example.com", id: "user" },
}), }),
), ),
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
accounts.map((account) => ({ accounts.map(
...account,
type: "BROKERAGE", account =>
openedOn: null, ({
portfolioSummary: null, ...account,
})), openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}),
),
), ),
), ),
), ),
@@ -150,13 +172,16 @@ beforeEach(() => {
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) => ({ params }) =>
HttpResponse.json( HttpResponse.json(
resource({
accountId: params.accountId, resource(
items, {
hasNext: false, accountId: params.accountId,
nextCursor: null, hasNext: false,
observedAt: receipt.occurredAt, items,
}), nextCursor: null,
observedAt: receipt.occurredAt,
},
),
), ),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
@@ -167,20 +192,24 @@ beforeEach(() => {
resource({ resource({
accountId: "all", accountId: "all",
calculatedAt: receipt.occurredAt, calculatedAt: receipt.occurredAt,
events: accounts.map(
account =>
({
account,
date: "2026-09-15",
dateKind: "event",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}),
),
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
events: accounts.map((account) => ({
account,
type: "coupon",
date: "2026-09-15",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "BOND",
displayName: "Облигация",
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
})),
}), }),
), ),
), ),
@@ -219,7 +248,11 @@ describe("All accounts routes", () => {
); );
for (const suffix of ["operations", "income", "events"]) for (const suffix of ["operations", "income", "events"])
expect( expect(
document.querySelector(`a[href="/accounts/all/${suffix}"]`), screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -228,24 +261,28 @@ describe("All accounts routes", () => {
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ request, params }) => { ({ 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({
accountId: params.accountId, resource(
items: [ {
{ accountId: params.accountId,
...receipt, hasNext: !url.searchParams.has("cursor"),
description: url.searchParams.has("cursor") items: [
? "Вторая страница" {
: "Первая страница", ...receipt,
}, description: url.searchParams.has("cursor")
], ? "Вторая страница"
hasNext: !url.searchParams.has("cursor"), : "Первая страница",
nextCursor: url.searchParams.has("cursor") ? null : "next", },
observedAt: receipt.occurredAt, ],
}), nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt,
},
),
); );
}, },
), ),
@@ -260,12 +297,14 @@ describe("All accounts routes", () => {
fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() => await waitFor(() =>
expect( expect(
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");
@@ -286,8 +325,8 @@ describe("All accounts routes", () => {
: HttpResponse.json( : HttpResponse.json(
resource({ resource({
accountId: "all", accountId: "all",
items,
hasNext: true, hasNext: true,
items,
nextCursor: "expired", nextCursor: "expired",
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
}), }),
@@ -305,7 +344,7 @@ describe("All accounts routes", () => {
}); });
it("retains the complete portfolio during a failed refresh", async () => { it("retains the complete portfolio during a failed refresh", async () => {
const client = renderPage("/accounts/all"); const view = renderPage("/accounts/all");
await screen.findAllByText("Сбербанк"); await screen.findAllByText("Сбербанк");
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/all/portfolio", () => http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
@@ -313,7 +352,7 @@ describe("All accounts routes", () => {
), ),
); );
await act(() => await act(() =>
client.invalidateQueries({ view.invalidateQueries({
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"), predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
}), }),
); );
+48 -48
View File
@@ -1,33 +1,35 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => { function renderRoutes(path: string) {
function renderRoutes(path: string) { return render(
return render( <QueryClientProvider client={createTestQueryClient()}>
<QueryClientProvider client={createTestQueryClient()}> <MemoryRouter initialEntries={[path]}>
<MemoryRouter initialEntries={[path]}> <MantineProvider
<MantineProvider cssVariablesResolver={cssVariablesResolver}
cssVariablesResolver={cssVariablesResolver} theme={theme}
theme={theme} >
> <AuthProvider>
<AuthProvider> <AppRoutes />
<AppRoutes /> </AuthProvider>
</AuthProvider> </MantineProvider>
</MantineProvider> </MemoryRouter>
</MemoryRouter> </QueryClientProvider>,
</QueryClientProvider>, );
); }
}
describe("AppRoutes", () => {
it.each([ it.each([
["/", "Вход"], ["/", "Вход"],
["/login", "Вход"], ["/login", "Вход"],
@@ -41,22 +43,18 @@ describe("AppRoutes", () => {
])("renders %s with its page title", async (path, title) => { ])("renders %s with its page title", async (path, title) => {
renderRoutes(path); renderRoutes(path);
await waitFor(() => expect(
expect( await screen.findByRole("heading", { level: 1, name: title }),
screen.getByRole("heading", { level: 1, name: title }), ).toBeInTheDocument();
).toBeInTheDocument(),
);
expect(document.title).toBe(`${title} — WealthLedger`); expect(document.title).toBe(`${title} — WealthLedger`);
}); });
it("redirects anonymous users from accounts", async () => { it("redirects anonymous users from accounts", async () => {
renderRoutes("/accounts"); renderRoutes("/accounts");
await waitFor(() => expect(
expect( await screen.findByRole("heading", { level: 1, name: "Вход" }),
screen.getByRole("heading", { level: 1, name: "Вход" }), ).toBeInTheDocument();
).toBeInTheDocument(),
);
}); });
it("logs in through the public form and reaches the protected landing", async () => { it("logs in through the public form and reaches the protected landing", async () => {
@@ -64,9 +62,10 @@ 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: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}); });
}), }),
); );
@@ -100,19 +99,22 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/login", () => http.post("*/api/v1/auth/login", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test", accessToken: "test",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
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({
month: "2026-08", HttpResponse.json(
cardId: null, {
expenses: { currency: "RUB", value: "0" }, cardId: null,
refunds: { currency: "RUB", value: "0" }, categories: [],
net: { currency: "RUB", value: "0" }, expenses: { currency: "RUB", value: "0" },
categories: [], month: "2026-08",
}), net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
},
),
), ),
); );
renderRoutes("/cards?month=2026-08"); renderRoutes("/cards?month=2026-08");
@@ -136,20 +138,18 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test-token", accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
); );
renderRoutes("/accounts"); renderRoutes("/accounts");
await waitFor(() => expect(
expect( await screen.findByRole("heading", {
screen.getByRole("heading", { level: 2,
level: 2, name: "Нет инвестиционных счетов",
name: "Нет инвестиционных счетов", }),
}), ).toBeInTheDocument();
).toBeInTheDocument(),
);
expect( expect(
screen.getByRole("navigation", { name: "WealthLedger" }), screen.getByRole("navigation", { name: "WealthLedger" }),
).toBeInTheDocument(); ).toBeInTheDocument();
+112 -60
View File
@@ -1,242 +1,294 @@
import type { FC } from "react"; import type { FC } from "react";
import { lazy } from "react"; import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router"; import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback"; import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards"; 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 path="/" element={<Navigate to="/accounts" replace />} /> <Route element={<Navigate replace to="/accounts" />} path="/" />
<Route <Route
path="/login" element={(
element={
<Public> <Public>
<LazyPage variant="auth"> <LazyPage variant="auth">
<LoginPage /> <LoginPage />
</LazyPage> </LazyPage>
</Public> </Public>
} )}
path="/login"
/> />
<Route <Route
path="/register" element={(
element={
<Public> <Public>
<LazyPage variant="auth"> <LazyPage variant="auth">
<RegisterPage /> <RegisterPage />
</LazyPage> </LazyPage>
</Public> </Public>
} )}
path="/register"
/> />
<Route <Route
element={ element={(
<Protected> <Protected>
<LazyPage variant="shell"> <LazyPage variant="shell">
<ProtectedLayout /> <ProtectedLayout />
</LazyPage> </LazyPage>
</Protected> </Protected>
} )}
> >
<Route <Route
path="/cards/:cardId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<CardPage /> <CardPage />
</LazyPage> </LazyPage>
} )}
path="/cards/:cardId"
/> />
<Route <Route
path="/cards" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<CardsPage /> <CardsPage />
</LazyPage> </LazyPage>
} )}
path="/cards"
/> />
<Route <Route
path="/bonds" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<BondsPage /> <BondsPage />
</LazyPage> </LazyPage>
} )}
path="/bonds"
/> />
<Route <Route
path="/bonds/:bondId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<BondPage /> <BondPage />
</LazyPage> </LazyPage>
} )}
path="/bonds/:bondId"
/> />
<Route <Route
path="/stocks" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<StocksPage /> <StocksPage />
</LazyPage> </LazyPage>
} )}
path="/stocks"
/> />
<Route <Route
path="/stocks/:stockId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<StockPage /> <StockPage />
</LazyPage> </LazyPage>
} )}
path="/stocks/:stockId"
/> />
<Route <Route
path="/accounts" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountsPage /> <AccountsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts"
/> />
<Route <Route
path="/accounts/all" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountPage accountId="all" /> <AccountPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all"
/> />
<Route <Route
path="/accounts/all/operations" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountOperationsPage accountId="all" /> <AccountOperationsPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/operations"
/> />
<Route <Route
path="/accounts/all/income" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountIncomePage accountId="all" /> <AccountIncomePage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/income"
/> />
<Route <Route
path="/accounts/all/events" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountEventsPage accountId="all" /> <AccountEventsPage accountId="all" />
</LazyPage> </LazyPage>
} )}
path="/accounts/all/events"
/> />
<Route <Route
path="/accounts/:accountId" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountPage /> <AccountPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId"
/> />
<Route <Route
path="/accounts/:accountId/events" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountEventsPage /> <AccountEventsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/events"
/> />
<Route <Route
path="/accounts/:accountId/income" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountIncomePage /> <AccountIncomePage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/income"
/> />
<Route <Route
path="/accounts/:accountId/operations" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<AccountOperationsPage /> <AccountOperationsPage />
</LazyPage> </LazyPage>
} )}
path="/accounts/:accountId/operations"
/> />
<Route <Route
path="/settings" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<SettingsPage /> <SettingsPage />
</LazyPage> </LazyPage>
} )}
path="/settings"
/> />
</Route> </Route>
<Route <Route
path="*" element={(
element={
<LazyPage variant="content"> <LazyPage variant="content">
<NotFoundPage /> <NotFoundPage />
</LazyPage> </LazyPage>
} )}
path="*"
/> />
</Routes> </Routes>
); );
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
act, act,
@@ -6,24 +8,27 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { http, HttpResponse } from "msw";
import type { FC } from "react";
import { MemoryRouter, useNavigate } from "react-router"; import { MemoryRouter, useNavigate } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { ProtectedDocumentTitle } from "./DocumentTitle"; import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => { const Navigation: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<button type="button" onClick={() => navigate("/accounts/all/income")}> <button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета Все счета
</button> </button>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
Назад Назад
</button> </button>
</> </>
@@ -36,6 +41,7 @@ const renderTitle = (path: string) =>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<AuthProvider> <AuthProvider>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<Navigation /> <Navigation />
</AuthProvider> </AuthProvider>
</MemoryRouter> </MemoryRouter>
@@ -48,7 +54,7 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ HttpResponse.json({
accessToken: "test-token", accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" }, user: { email: "user@example.com", id: "user-1" },
}), }),
), ),
); );
@@ -56,19 +62,19 @@ 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 () => {
let resolveAccounts!: () => void; const { promise: accountsReady, resolve: resolveAccounts }
const accountsReady = new Promise<void>((resolve) => { = Promise.withResolvers<void>();
resolveAccounts = resolve;
});
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: "ИИС" }]));
}), }),
); );
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
expect(document.title).toBe("Пассивный доход — Счет — WealthLedger"); expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
await act(async () => resolveAccounts()); await act(() => Promise.resolve(resolveAccounts()));
await waitFor(() => await waitFor(() =>
expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"), expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
); );
@@ -98,15 +104,16 @@ describe("document titles", () => {
); );
it("keeps a useful title when the instrument is unavailable", async () => { it("keeps a useful title when the instrument is unavailable", async () => {
let requested = false; let isRequested = false;
server.use( server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => { http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
requested = true; isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}), }),
); );
renderTitle("/stocks/missing"); renderTitle("/stocks/missing");
await waitFor(() => expect(requested).toBe(true)); await waitFor(() => expect(isRequested).toBe(true));
expect(document.title).toBe("Акция — WealthLedger"); expect(document.title).toBe("Акция — WealthLedger");
}); });
}); });
+43 -30
View File
@@ -1,7 +1,9 @@
import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -9,10 +11,9 @@ import { useStock } from "@/entities/stock";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props) => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`); useDocumentTitle(`${props.title} — ${t("appName")}`);
return null;
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -23,19 +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 =
props.accountId === "all" const accountName
= 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"));
const section =
props.section === "income" let section = t("portfolio.title");
? t("income.title")
: props.section === "operations" switch (props.section ?? "") {
? t("ledger.title") case "income": {
: props.section === "events" section = t("income.title");
? t("events.title") break;
: t("portfolio.title"); }
case "operations": {
section = t("ledger.title");
break;
}
case "events": {
section = t("events.title");
break;
}
default: {
break;
}
}
return <DocumentTitle title={`${section} — ${accountName}`} />; return <DocumentTitle title={`${section} — ${accountName}`} />;
}; };
@@ -44,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")} />;
}; };
@@ -80,21 +101,13 @@ const ProtectedDocumentTitle: FC = () => {
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />; if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />; return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
return ( if (cards) return <DocumentTitle title={t("navigation.cards")} />;
<DocumentTitle if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
title={t( if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
cards
? "navigation.cards" return settings
: bonds ? <DocumentTitle title={t("navigation.settings")} />
? "navigation.bonds" : <DocumentTitle title={t("navigation.accounts")} />;
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
)}
/>
);
}; };
export { ProtectedDocumentTitle }; export { ProtectedDocumentTitle };
+3
View File
@@ -1,11 +1,14 @@
import type { FC } from "react"; import type { FC } from "react";
import { AppBreadcrumbs, AppShell } from "@/app/ui"; import { AppBreadcrumbs, AppShell } from "@/app/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle"; import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle";
const ProtectedLayout: FC = () => { const ProtectedLayout: FC = () => {
return ( return (
<> <>
<ProtectedDocumentTitle /> <ProtectedDocumentTitle />
<AppShell contentAboveOutlet={<AppBreadcrumbs />} /> <AppShell contentAboveOutlet={<AppBreadcrumbs />} />
</> </>
); );
@@ -1,13 +1,17 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import type { FC } from "react"; import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { RouteErrorBoundary } from "./RouteErrorBoundary";
let authenticated = false; const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({ vi.mock("@/features/auth", () => ({
useAuth: () => ({ user: authenticated ? {} : null }),
useAuth: () =>
({ user: session.isAuthenticated ? {} : null }),
})); }));
const ThrowingRoute: FC = () => { const ThrowingRoute: FC = () => {
@@ -17,11 +21,11 @@ const ThrowingRoute: FC = () => {
const HealthyRoute: FC = () => { const HealthyRoute: FC = () => {
return ( return (
<div> <div>
{useLocation().pathname === "/healthy" ? ( {useLocation().pathname === "/healthy"
"healthy route" ? (
) : ( "healthy route"
<ThrowingRoute /> )
)} : <ThrowingRoute />}
</div> </div>
); );
}; };
@@ -31,9 +35,10 @@ const RouteHarness: FC = () => {
return ( return (
<> <>
<button type="button" onClick={() => navigate("/healthy")}> <button onClick={() => navigate("/healthy")} type="button">
go healthy go healthy
</button> </button>
<RouteErrorBoundary> <RouteErrorBoundary>
<HealthyRoute /> <HealthyRoute />
</RouteErrorBoundary> </RouteErrorBoundary>
@@ -52,8 +57,81 @@ function renderBoundary(path: string) {
} }
describe("RouteErrorBoundary", () => { describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself",
async (path) => {
session.isAuthenticated = path === "/accounts";
let isFailing = true;
const RecoverableRoute: FC = () => {
if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteErrorBoundary>
<RecoverableRoute />
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
const action = screen.getByRole("link", { name: "Продолжить" });
isFailing = false;
fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument();
},
);
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
session.isAuthenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect(
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
).toBeInTheDocument();
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Перезагрузить страницу" }),
).toHaveAttribute("href", "/accounts");
});
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
session.isAuthenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/login"]}>
<RouteErrorBoundary>
<Suspense fallback={<div>loading chunk</div>}>
<Chunk />
</Suspense>
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
expect(load).toHaveBeenCalledTimes(1);
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => {
isIntercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(isIntercepted).toBe(false);
});
it("offers login after a public route error without error details", () => { it("offers login after a public route error without error details", () => {
authenticated = false; session.isAuthenticated = false;
renderBoundary("/login"); renderBoundary("/login");
expect(document.title).toBe("Не удалось открыть страницу — WealthLedger"); expect(document.title).toBe("Не удалось открыть страницу — WealthLedger");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
@@ -64,11 +142,9 @@ describe("RouteErrorBoundary", () => {
}); });
it("offers accounts after a protected route error and resets on navigation", async () => { it("offers accounts after a protected route error and resets on navigation", async () => {
authenticated = true; session.isAuthenticated = true;
renderBoundary("/accounts"); renderBoundary("/accounts");
screen.getByRole("button", { name: "go healthy" }).click(); fireEvent.click(screen.getByRole("button", { name: "go healthy" }));
await waitFor(() => expect(await screen.findByText("healthy route")).toBeInTheDocument();
expect(screen.getByText("healthy route")).toBeInTheDocument(),
);
}); });
}); });
@@ -1,14 +1,16 @@
import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks"; import { useDocumentTitle } from "@mantine/hooks";
import type { FC, PropsWithChildren } from "react";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
routeKey: string;
authenticated: boolean; authenticated: boolean;
routeKey: string;
}>; }>;
type RouteErrorFallbackProps = { type RouteErrorFallbackProps = {
@@ -18,12 +20,12 @@ type RouteErrorFallbackProps = {
type State = { hasError: boolean }; type State = { hasError: boolean };
class Boundary extends Component<BoundaryProps, State> { class Boundary extends Component<BoundaryProps, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State { static getDerivedStateFromError(): State {
return { hasError: true }; return { hasError: true };
} }
state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) { componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false }); this.setState({ hasError: false });
@@ -31,10 +33,11 @@ class Boundary extends Component<BoundaryProps, State> {
} }
render() { render() {
if (this.state.hasError) { return this.state.hasError
return <RouteErrorFallback authenticated={this.props.authenticated} />; ? <RouteErrorFallback authenticated={this.props.authenticated} />
} : (
return this.props.children; this.props.children
);
} }
} }
@@ -47,21 +50,30 @@ 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">
{t("routeError.reload")}
</Button>
</Stack> </Stack>
); );
}; };
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => { const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { pathname, search } = useLocation(); const { key, pathname, search } = useLocation();
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}> <Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children} {children}
</Boundary> </Boundary>
); );

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