Compare commits

...
28 Commits
Author SHA1 Message Date
ksv741 54deae57b3 chore(release): 1.0.0
Frontend image / publish (push) Successful in 7m39s
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m39s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m34s
2026-10-07 18:14:21 +03:00
ksv741 74f8a72ed5 perf(tbank-connection): poll screen sync every five seconds
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m57s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m21s
During active screen sync, status checks and observe reads run every 5s
instead of 1s; terminal state observes resources immediately.
OpenSpec change: WealthLedger/specs#21
2026-10-07 14:03:55 +03:00
ksv741 fcb938b5a2 chore: remove unused exports
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m16s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 14s
Frontend verify / verify (push) Failing after 6m28s
2026-10-07 09:10:03 +03:00
ksv741 f873db7b02 fix(deps): resolve npm audit advisories
Frontend verify / commits (pull_request) Successful in 36s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 32m34s
2026-10-07 07:27:16 +03:00
ksv741 0ea8d7b62d feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped
2026-10-06 22:05:41 +03:00
ksv741 c25f6bd85b refactor: move PortfolioStructure to widgets layer 2026-10-06 22:05:24 +03:00
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

Versions and integrity hashes remain unchanged.
2026-10-03 22:09:33 +03:00
ksv741 69997075e5 refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00
ksv741 6a89b08ee5 chore: guard presentation data boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 10m18s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 59s
Frontend verify / verify (push) Successful in 10m53s
2026-10-01 20:53:23 +03:00
ksv741 2844b22dba refactor: simplify finance view models 2026-10-01 20:53:13 +03:00
ksv741 ff67a768ad refactor: unify bank form types and actions 2026-10-01 20:53:00 +03:00
ksv741 10baefda79 fix(deps): resolve npm audit advisories
Refresh vulnerable transitive patch versions.
Scope patched undici and basic-ftp to release-it.
Retain the low audit threshold and release tooling version.
2026-10-01 20:51:32 +03:00
ksv741 f5f4447d60 fix: sort stock and bond events by payment date 2026-09-30 07:40:43 +03:00
ksv741 0bc8bc36b7 refactor: share source sync status presentation 2026-09-30 07:40:23 +03:00
ksv741 f9f0f6921e refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-09-29 07:07:36 +03:00
ksv741 7c28a423dd refactor: tighten types and reuse instrument UI
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 06:15:31 +03:00
ksv741 b404c1802a refactor: simplify auth and instrument pages
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:45:08 +03:00
ksv741 32a77860c8 refactor: share instrument search presentation
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:14:28 +03:00
ksv741 0865b35f93 refactor: clarify frontend model ownership
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m20s
2026-09-28 21:44:53 +03:00
ksv741 7e976c330c refactor: simplify errors and focus tests
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m25s
2026-09-28 21:25:28 +03:00
ksv741 320ea5922a test: remove redundant frontend checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 8m31s
2026-09-28 21:04:27 +03:00
ksv741 64fe902192 refactor: encapsulate instrument chart data
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m45s
2026-09-28 20:46:06 +03:00
ksv741 fb3a5a6eaa refactor: remove redundant page view models
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m35s
2026-09-28 20:34:33 +03:00
ksv741 94adbafa34 refactor: simplify calendar and form state
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m43s
2026-09-28 20:23:02 +03:00
ksv741 a7537da344 refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
2026-09-28 20:01:37 +03:00
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
389 changed files with 14034 additions and 10877 deletions
+13 -10
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → features → entities → shared app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,13 +30,15 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`. Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
сами по себе не требуют feature. Два callers не делают domain helper generic shared. одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner. **Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -47,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
@@ -69,10 +71,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`.
## Примеры ## Примеры
+13
View File
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller. Не добавляй собственный refresh/retry loop в caller.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage, httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш. новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner; Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior. нужен конкретному UX и обязан иметь rollback/error behavior.
+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 иллюстрирует собственный
+1 -1
View File
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). [resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+35
View File
@@ -17,6 +17,41 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: 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 проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
и многострочное преобразование — отдельная функция с guard и `if/else`.
- Union из object shapes оформляй несколькими строками; nullable scalar
`null | string` остаётся компактным. Для повторяемого shape используй named type.
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
перед следующим required параметром может сохранять `T | undefined`.
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
## Hooks и derived state ## Hooks и derived state
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
+51 -13
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,40 @@ 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.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
type imports, error helpers, router components и form context разрешены.
Транзитивный fetching через local presentation hook проверяется review;
Storybook дополнительно проверяет reachable graph. Новое исключение должно
сохранять presentation-only responsibility.
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, когда внутри смешиваются независимые
сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
простое подключение одного controller остаётся в VM формы.
Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления.
Render-helper замени самостоятельным presentation component у того же owner.
Отдельный state/section с самостоятельной responsibility выноси в Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй `components/` своего component. Небольшой tightly-coupled renderer оставляй
@@ -82,11 +116,15 @@ image fallback) и formatter одного переданного значени
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css` лежит рядом со своим view, тестом и story. Формы используют Mantine
остаётся в `bank-card/ui`. `Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
## Формы ## Формы
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`. form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
Во время submit блокируй все действия формы. Zod errors показывай у поля; Во время submit блокируй все действия формы. Zod errors показывай у поля;
+7 -2
View File
@@ -5,11 +5,16 @@ module.exports = {
forbidden: [ forbidden: [
...base.forbidden, ...base.forbidden,
{ {
from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` }, from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" },
name: "stories-must-not-reach-application-data", name: "stories-must-not-reach-application-data",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`, path: [
"^src/(shared/api|features/auth|app/routes|app/ui)/",
"^src/(entities|features)/[^/]+/api/",
"^src/pages/[^/]+/ui/.*Page[.]tsx$",
"node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
],
reachable: true, reachable: true,
}, },
}, },
+4 -2
View File
@@ -1,12 +1,14 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css"; import "@mantine/charts/styles.css";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@wealthledger/icons/styles.css";
import "@wealthledger/ui/styles.css"; import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import "../src/shared/i18n";
import type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react-vite";
import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
+67
View File
@@ -1,5 +1,72 @@
# Changelog # Changelog
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
### Features
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
### Bug Fixes
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
### Performance
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
### Refactoring
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
### CI
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
### Tests
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
### Styles
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
### Maintenance
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26) ## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features ### Features
+11 -11
View File
@@ -262,14 +262,14 @@ Library build использует отдельный Vite config без app plu
Начальная версия manifest взята из приложения; отдельный release-процесс Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены. и публикация не настроены.
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon` Private `@wealthledger/icons` содержит все SVG приложения: действия,
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`; навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion. ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5; подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
цвет наследуется через `currentColor`, SVG props можно передать явно. иконок. Category/kind mapping остаётся в model `entities/bank-card`.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
доступное имя задавайте кнопке. Standalone пример: Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`. `strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller. имя принадлежит действию. Для самостоятельного изображения передайте
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth. `aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
Restore/category SVG и предметный BankOperationIcon остаются в приложении. Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`.
+20 -5
View File
@@ -1,5 +1,14 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{
from: {
path: "^(src|packages/[^/]+/src)/",
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
},
name: "production-must-not-depend-on-test-code",
severity: "error",
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
},
{ {
from: {}, from: {},
name: "no-unresolved", name: "no-unresolved",
@@ -20,17 +29,17 @@ module.exports = {
}, },
{ {
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" }, from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -39,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" }, from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" }, from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" }, from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
+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 } };
+206 -45
View File
@@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals"; import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
export default defineConfig( export default defineConfig(
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
plugins: { frontend },
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
},
globalIgnores([ globalIgnores([
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**", "**/dist/**",
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**", "dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]), ]),
{ {
extends: [ extends: [
@@ -35,32 +49,91 @@ export default defineConfig(
security.configs.recommended, security.configs.recommended,
noUnsanitized.configs.recommended, noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"], perfectionist.configs["recommended-natural"],
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }), stylistic.configs.customize({
indent: "tab",
jsx: true,
quotes: "double",
semi: true,
}),
], ],
files: ["**/*.{js,mjs,cjs,ts,tsx}"], files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" }, linterOptions: { reportUnusedDisableDirectives: "error" },
rules: { rules: {
// A comma disambiguates generic arrow functions from JSX in TSX. // A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": ["error", { "@stylistic/comma-dangle": [
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline", "error",
functions: "always-multiline", generics: "ignore", imports: "always-multiline", {
objects: "always-multiline", tuples: "always-multiline", arrays: "always-multiline",
}], enums: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
generics: "ignore",
imports: "always-multiline",
objects: "always-multiline",
tuples: "always-multiline",
},
],
// Keep text/interpolation together: splitting can change rendered whitespace. // Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }], "@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/max-len": [
"error",
{
code: 80,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
"@stylistic/multiline-ternary": ["error", "always-multiline"], "@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }], "@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"], "@stylistic/operator-linebreak": ["error", "before"],
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }], "@stylistic/padding-line-between-statements": [
"error",
{ blankLine: "always", next: "return", prev: "*" },
{
blankLine: "always",
next: "*",
prev: ["multiline-const", "multiline-let", "block-like", "type"],
},
{
blankLine: "always",
next: ["multiline-const", "multiline-let", "block-like", "type"],
prev: "*",
},
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
],
"no-nested-ternary": "error",
"perfectionist/sort-classes": [
"error",
{
groups: ["static-method", "property", "method", "unknown"],
type: "natural",
},
],
// Preserve the repository's component, hook and config file naming conventions. // Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }], "unicorn/filename-case": [
"error",
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
],
// React uses Props/Ref names, including the recommended React ref-name rule. // React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }], "unicorn/name-replacements": [
// React, Mantine and Fetch accept null at their documented call boundaries. "error",
"unicorn/no-null": ["error", { checkArguments: false }], { replacements: { props: false, ref: false } },
],
// API DTOs, React refs and Mantine use null as part of their contracts.
"unicorn/no-null": "off",
"unicorn/prefer-ternary": ["error", "only-single-line"],
}, },
settings: { settings: {
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })], "import-x/resolver-next": [
createTypeScriptImportResolver({ project: "tsconfig.json" }),
],
}, },
}, },
{ {
@@ -68,18 +141,39 @@ export default defineConfig(
languageOptions: { globals: globals.node }, languageOptions: { globals: globals.node },
}, },
{ {
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } }, languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error",
// React ignores event handler return values; floating promises are still checked in bodies. // React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }], "@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
{ {
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]], extends: [
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"], react.configs["recommended-type-checked"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
languageOptions: { globals: globals.browser }, languageOptions: { globals: globals.browser },
rules: { rules: {
"import-x/exports-last": "error", "import-x/exports-last": "error",
@@ -91,14 +185,59 @@ export default defineConfig(
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
}, },
{
files: ["src/**/*.view.tsx"],
rules: {
"no-restricted-imports": ["error", {
paths: [
{
allowTypeImports: true,
message: "Query lifecycle belongs to the data VM.",
name: "@tanstack/react-query",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-fetch",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-react-query",
},
],
patterns: [
{
allowTypeImports: true,
group: ["react-router", "@/entities/*", "@/features/*"],
importNamePattern: "^use",
message: "Pass route, auth and entity data from the data VM.",
},
{
allowTypeImports: true,
group: ["@/shared/api", "@/shared/api/*"],
importNamePattern: "^use|^api(?:Query)?Client$",
message: "Fetching belongs to the data VM; error helpers are allowed.",
},
],
}],
},
},
{ {
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: { rules: {
"@typescript-eslint/no-magic-numbers": ["error", { "@typescript-eslint/no-magic-numbers": [
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true, "error",
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true, {
}], ignore: [-1, 0, 1, "-1n", "0n", "1n"],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
ignoreTypeIndexes: true,
},
],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
}, },
}, },
{ {
@@ -110,51 +249,73 @@ export default defineConfig(
}, },
}, },
{ {
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]], extends: [
testingLibrary.configs["flat/react"],
jestDom.configs["flat/recommended"],
],
files: ["**/*.test.tsx"], files: ["**/*.test.tsx"],
}, },
storybook.configs["flat/recommended"], storybook.configs["flat/recommended"],
{ {
files: ["**/*.stories.tsx", ".storybook/preview.tsx"], files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" }, rules: {
"import-x/exports-last": "off",
"import-x/no-default-export": "off",
},
}, },
{ {
files: [ files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
"src/**/*.{ts,tsx}", ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
"packages/*/src/**/*.{ts,tsx}",
],
ignores: [
"**/*.test.{ts,tsx}",
"**/*.stories.tsx",
"src/test/**",
],
rules: { rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error", "@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [ "no-restricted-syntax": [
"error", "error",
{ {
message: "Use the owning enum member for machine codes and protocol values.", message:
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)", "Pass a named handler from the ViewModel; prepare prop data before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
}, },
{ {
message: "Use the owning enum member instead of a template machine code.", message: "Name the derived prop value before JSX.",
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)", selector:
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
},
{
message:
"Use the owning enum member for machine codes and protocol values.",
selector:
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
}, },
{ {
message: "Use an enum member instead of an inline code or protocol value.", message:
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]", "Use the owning enum member instead of a template machine code.",
selector:
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
}, },
{ {
message: "Use an enum member instead of constructing a code or protocol value.", message:
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value", "Use an enum member instead of an inline code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message:
"Use an enum member instead of constructing a code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
}, },
{ {
message: "Use a computed enum member as the protocol mapping key.", message: "Use a computed enum member as the protocol mapping key.",
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]", selector:
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
}, },
{ {
message: "Compare codes with their enum members, not string literals.", message:
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]", "Compare codes with their enum members, not string literals.",
selector:
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
}, },
], ],
}, },
+112 -1305
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "0.4.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.21.0"
@@ -23,7 +23,7 @@
"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 && 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", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && 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",
@@ -34,7 +34,7 @@
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0" "lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
@@ -42,6 +42,7 @@
"@mantine/core": "9.6.1", "@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1", "@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.6.1",
"@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.102.8", "@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
@@ -114,6 +115,12 @@
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.0"
}, },
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
},
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
"fsevents@2.3.3": true "fsevents@2.3.3": true
+5 -2
View File
@@ -10,9 +10,12 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./styles.css": "./dist/index.css"
}, },
"sideEffects": false, "sideEffects": [
"**/*.css"
],
"scripts": { "scripts": {
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
+5
View File
@@ -17,6 +17,7 @@ const meta = {
} satisfies Meta<typeof AddIcon>; } satisfies Meta<typeof AddIcon>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Decorative: Story = { export const Decorative: Story = {
@@ -25,9 +26,11 @@ export const Decorative: Story = {
<button aria-label="Добавить пример" type="button"> <button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} /> <AddIcon size={20} strokeWidth={2} />
</button> </button>
<button aria-label="Изменить пример" type="button"> <button aria-label="Изменить пример" type="button">
<EditIcon size={18} /> <EditIcon size={18} />
</button> </button>
<button aria-label="Удалить пример" type="button"> <button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} /> <DeleteIcon size={18} />
</button> </button>
@@ -39,7 +42,9 @@ export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" /> <AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" /> <EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" /> <DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
+14 -1
View File
@@ -1,7 +1,7 @@
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, OperationIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])( it.each([AddIcon, EditIcon, DeleteIcon])(
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
<Icon /> <Icon />
</button>, </button>,
); );
expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect( expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
@@ -33,4 +34,16 @@ describe("icon accessibility", () => {
expect(icon).toHaveAttribute("stroke", "currentColor"); expect(icon).toHaveAttribute("stroke", "currentColor");
}, },
); );
it("keeps operation icons decorative", () => {
render(
<button aria-label="Операция" type="button">
<OperationIcon color="orange" variant="home" />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
"aria-hidden", "true",
);
});
}); });
@@ -1,4 +1,5 @@
.icon { .icon {
flex-shrink: 0;
overflow: visible; overflow: visible;
& path { & path {
@@ -0,0 +1,72 @@
import type { FC } from "react";
import { BasketIcon } from "./svg/BasketIcon";
import { CarIcon } from "./svg/CarIcon";
import { CashWithdrawalIcon } from "./svg/CashWithdrawalIcon";
import { CoffeeIcon } from "./svg/CoffeeIcon";
import { DepositIcon } from "./svg/DepositIcon";
import { DiceIcon } from "./svg/DiceIcon";
import { DownloadIcon } from "./svg/DownloadIcon";
import { EducationIcon } from "./svg/EducationIcon";
import { GridIcon } from "./svg/GridIcon";
import { GrowthIcon } from "./svg/GrowthIcon";
import { HomeIcon } from "./svg/HomeIcon";
import { MedicalIcon } from "./svg/MedicalIcon";
import { MusicIcon } from "./svg/MusicIcon";
import { RefundIcon } from "./svg/RefundIcon";
import { RepaymentIcon } from "./svg/RepaymentIcon";
import { ServerIcon } from "./svg/ServerIcon";
import { ShieldIcon } from "./svg/ShieldIcon";
import { ShoppingIcon } from "./svg/ShoppingIcon";
import { SmokingIcon } from "./svg/SmokingIcon";
import { SubscriptionsIcon } from "./svg/SubscriptionsIcon";
import { TaxiIcon } from "./svg/TaxiIcon";
import { TopupIcon } from "./svg/TopupIcon";
import { TransferIcon } from "./svg/TransferIcon";
import { TravelIcon } from "./svg/TravelIcon";
import { WifiIcon } from "./svg/WifiIcon";
import { WineIcon } from "./svg/WineIcon";
const icons = {
basket: BasketIcon,
car: CarIcon,
cashWithdrawal: CashWithdrawalIcon,
coffee: CoffeeIcon,
deposit: DepositIcon,
dice: DiceIcon,
download: DownloadIcon,
education: EducationIcon,
grid: GridIcon,
growth: GrowthIcon,
home: HomeIcon,
medical: MedicalIcon,
music: MusicIcon,
refund: RefundIcon,
repayment: RepaymentIcon,
server: ServerIcon,
shield: ShieldIcon,
shopping: ShoppingIcon,
smoking: SmokingIcon,
subscriptions: SubscriptionsIcon,
taxi: TaxiIcon,
topup: TopupIcon,
transfer: TransferIcon,
travel: TravelIcon,
wifi: WifiIcon,
wine: WineIcon,
};
type OperationIconProps = {
color: string;
variant: keyof typeof icons;
};
const OperationIcon: FC<OperationIconProps> = (props) => {
const { color, variant } = props;
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed key of the static icon registry.
const Icon = icons[variant];
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
};
export { OperationIcon, type OperationIconProps };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const BasketIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.basket}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
<path d="M8 9l4-6 4 6" pathLength={1} />
<path d="M9 13v3" pathLength={1} />
<path d="M15 13v3" pathLength={1} />
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
</svg>
);
};
export { BasketIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CarIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.car}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
<path d="M8 16h8" pathLength={1} />
<path d="M6 11h12m-6-4v4" pathLength={1} />
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
</svg>
);
};
export { CarIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CashWithdrawalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10V3h16v7" pathLength={1} />
<path d="M7 7h10v14H7V7Z" pathLength={1} />
<path d="M7 11h10" pathLength={1} />
<path d="M10 16h4" pathLength={1} />
</svg>
);
};
export { CashWithdrawalIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CoffeeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.coffee}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M3 22h15" pathLength={1} />
</svg>
);
};
export { CoffeeIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DepositIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.deposit}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
</svg>
);
};
export { DepositIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DiceIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
<path d="M7 7h.01" pathLength={1} />
<path d="M17 7h.01" pathLength={1} />
<path d="M12 12h.01" pathLength={1} />
<path d="M7 17h.01" pathLength={1} />
<path d="M17 17h.01" pathLength={1} />
</svg>
);
};
export { DiceIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DownloadIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v13H3V3Z" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
<path d="M12 16v5" pathLength={1} />
<path d="M12 6v7" pathLength={1} />
<path d="M9 10l3 3 3-3" pathLength={1} />
</svg>
);
};
export { DownloadIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const EducationIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
<path d="M22 8v9" pathLength={1} />
</svg>
);
};
export { EducationIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GridIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.grid}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 4h6v6H4V4Z" pathLength={1} />
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
</svg>
);
};
export { GridIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GrowthIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.growth}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" pathLength={1} />
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
<path d="M16 5h5v5" pathLength={1} />
</svg>
);
};
export { GrowthIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const HomeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 11l9-8 9 8" pathLength={1} />
<path d="M5 10v11h14V10" pathLength={1} />
<path d="M9 21v-8h6v8" pathLength={1} />
</svg>
);
};
export { HomeIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MedicalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
</svg>
);
};
export { MedicalIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MusicIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 9l11-2" pathLength={1} />
</svg>
);
};
export { MusicIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RefundIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M8 4L3 9l5 5" pathLength={1} />
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
</svg>
);
};
export { RefundIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RepaymentIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.repayment}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Z" pathLength={1} />
<path d="M3 9h18" pathLength={1} />
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
</svg>
);
};
export { RepaymentIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ServerIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.server}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v7H3V3Z" pathLength={1} />
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
<path d="M7 6.5h.01" pathLength={1} />
<path d="M7 17.5h.01" pathLength={1} />
<path d="M15 6.5h3" pathLength={1} />
<path d="M15 17.5h3" pathLength={1} />
</svg>
);
};
export { ServerIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShieldIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
<path d="M8 12l3 3 5-6" pathLength={1} />
</svg>
);
};
export { ShieldIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShoppingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
</svg>
);
};
export { ShoppingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SmokingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
<path d="M16 14v5" pathLength={1} />
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
</svg>
);
};
export { SmokingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SubscriptionsIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
<path d="M20 3v4h-4" pathLength={1} />
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
<path d="M4 21v-4h4" pathLength={1} />
</svg>
);
};
export { SubscriptionsIcon };
@@ -0,0 +1,44 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TaxiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10l2-6h12l2 6" pathLength={1} />
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
<path d="M6 18v3" pathLength={1} />
<path d="M18 18v3" pathLength={1} />
<path d="M6 14h2" pathLength={1} />
<path d="M16 14h2" pathLength={1} />
<path d="M10 4V2h4v2" pathLength={1} />
</svg>
);
};
export { TaxiIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TopupIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.topup}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
</svg>
);
};
export { TopupIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TransferIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.transfer}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7h18l-4-4" pathLength={1} />
<path d="M21 17H3l4 4" pathLength={1} />
</svg>
);
};
export { TransferIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TravelIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
</svg>
);
};
export { TravelIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WifiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wifi}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
<path d="M12 19h.01" pathLength={1} />
</svg>
);
};
export { WifiIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WineIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wine}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
<path d="M12 13v8" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
</svg>
);
};
export { WineIcon };

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

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

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