Compare commits
5
Commits
v1.0.0
..
f876411a37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f876411a37 | ||
|
|
5b2c6bb7cb | ||
|
|
60f9538886 | ||
|
|
fd73466073 | ||
|
|
edd54892cd |
+12
-15
@@ -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 → widgets → features → entities → shared
|
app → pages → 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,15 +30,13 @@ 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 → widgets → features → entities → shared`.
|
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
|
||||||
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
|
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
|
||||||
одной page остаётся у page. Form, mutation или click handler сами по себе не
|
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
|
||||||
требуют feature. Два callers не делают domain helper generic shared.
|
|
||||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||||
|
|
||||||
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
||||||
@@ -49,12 +47,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`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
||||||
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
||||||
не являются разрешением добавлять новые.
|
не являются разрешением добавлять новые.
|
||||||
|
|
||||||
Направление зависимостей, public imports и запрет соседних slices в
|
Направление зависимостей, public imports и запрет соседних slices в
|
||||||
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
||||||
одного slice и связи shared segments этот запрет не ограничивает.
|
одного slice и связи shared segments этот запрет не ограничивает.
|
||||||
Knip проверяет использование exports, а не смысл placement.
|
Knip проверяет использование exports, а не смысл placement.
|
||||||
|
|
||||||
@@ -71,16 +69,15 @@ 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).
|
||||||
|
|
||||||
Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
|
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
|
||||||
Приложение импортирует компоненты из `@wealthledger/icons` и подключает
|
`packages/icons`; package не зависит от UI, domain или motion.
|
||||||
`@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов,
|
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
|
||||||
единственная задача которых — рисовать иконку. Domain mapping категории в
|
Add/Edit/Delete; restore/category SVG остаются локальными.
|
||||||
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
|
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx), а не domain widget в shared.
|
- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton.tsx), а не domain widget в shared.
|
||||||
- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx) и её `components/`; новый page-local filter остаётся здесь. Комплекты файлов группируются по [UI convention](conventions/ui.md#размещение-файлов-компонента).
|
- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage.tsx) и её flat `components/*.view.tsx`; новый page-local filter остаётся здесь.
|
||||||
- Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
|
- Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|||||||
+1
-14
@@ -39,10 +39,6 @@ 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,
|
||||||
@@ -54,15 +50,6 @@ 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.
|
||||||
@@ -83,7 +70,7 @@ Network/unknown error получает безопасное локальное
|
|||||||
|
|
||||||
Если одна группа API codes требует одинакового recovery в нескольких views,
|
Если одна группа API codes требует одинакового recovery в нескольких views,
|
||||||
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
|
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
|
||||||
и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`.
|
и не объявляй закрытый frontend error enum: generated `code` пока `string`.
|
||||||
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
|
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
|
||||||
разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
|
разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ owner и 1–3 примера. Код показывает механизм; с
|
|||||||
## Domain values
|
## Domain values
|
||||||
|
|
||||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||||
Переиспользуй его symbols; для machine codes и protocol values используй
|
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
||||||
строковые `enum`, для структуры с разными payload — discriminated union.
|
|
||||||
Сохраняй wire values; generated/external enums не переписывай вручную.
|
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||||
обычные labels не выноси в constants без semantic benefit.
|
обычные labels не выноси в constants без semantic benefit.
|
||||||
|
|||||||
@@ -19,16 +19,14 @@ 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 и query providers даже транзитивно.
|
Stories не достигают application API, auth, VM и 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 иллюстрирует собственный
|
||||||
контракт: keyboard activation, доступность действия, local callback. Не повторяй
|
контракт: keyboard activation, доступность действия, local callback. Не повторяй
|
||||||
RTL suite и не тестируй внутренности Mantine. Существующий
|
RTL suite и не тестируй внутренности Mantine. Существующий
|
||||||
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
|
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
|
||||||
без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx)
|
без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton.stories.tsx)
|
||||||
— значимые busy states.
|
— значимые busy states.
|
||||||
|
|
||||||
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
|
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ Finance: loading/error/empty/loaded, cached snapshot при refetch failure, ret
|
|||||||
отсутствие старых данных после session/connection switch. ErrorBoundary: test-only
|
отсутствие старых данных после session/connection switch. ErrorBoundary: test-only
|
||||||
throwing route, безопасная ссылка и reset после navigation.
|
throwing route, безопасная ссылка и reset после navigation.
|
||||||
|
|
||||||
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
|
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm.test.tsx),
|
||||||
[client test](../../src/shared/api/client.test.ts),
|
[client test](../../src/shared/api/client.test.ts),
|
||||||
[resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
|
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
|
||||||
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
|
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
|
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
|
||||||
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
|
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
|
||||||
на нетипизированной boundary должно быть узким и объяснённым.
|
на нетипизированной boundary должно быть узким и объяснённым.
|
||||||
- Строковые `enum` используются для machine codes и protocol values.
|
- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`.
|
||||||
`namespace` не используется; tooling сохраняет `erasableSyntaxOnly`.
|
|
||||||
Для состояния с разными payload выбирай discriminated union; не добавляй
|
Для состояния с разными payload выбирай discriminated union; не добавляй
|
||||||
необязательные поля, допускающие невозможные комбинации.
|
необязательные поля, допускающие невозможные комбинации.
|
||||||
- Named components — const-valued `FC<Props>`; named props находятся рядом или
|
- Named components — const-valued `FC<Props>`; named props находятся рядом или
|
||||||
@@ -15,42 +14,7 @@
|
|||||||
на основе `PropsWithChildren<{ … }>`; без props — `FC`.
|
на основе `PropsWithChildren<{ … }>`; без props — `FC`.
|
||||||
- Обычные exports — нижний named block; barrel делает прямой re-export.
|
- Обычные exports — нижний named block; barrel делает прямой re-export.
|
||||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
заданы в [Biome](../../biome.json). Не распространяй их на 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
|
||||||
|
|
||||||
@@ -69,23 +33,19 @@ Container/VM/view и state owners — в [UI](ui.md).
|
|||||||
|
|
||||||
Общее правило ownership и символических значений —
|
Общее правило ownership и символических значений —
|
||||||
[Domain values](engineering.md#domain-values).
|
[Domain values](engineering.md#domain-values).
|
||||||
Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй.
|
Backend vocabulary бери из generated `paths`, не создавай ручную копию enum.
|
||||||
Для открытых `string` полей допускается локальный enum известных значений,
|
Текущий `schema.d.ts` содержит только types: typed wire literals допустимы,
|
||||||
используемых UI. Он не закрывает wire contract и сохраняет unknown fallback.
|
когда generator не предоставляет runtime values.
|
||||||
Если generator предоставляет runtime enum, используй его.
|
|
||||||
|
|
||||||
`ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract.
|
`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error
|
||||||
`apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное
|
enum и не превращай неизвестный code в известный через cast. Повторяющуюся
|
||||||
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
|
|
||||||
обработку своди у ответственного owner; [errors](data.md#errors).
|
обработку своди у ответственного owner; [errors](data.md#errors).
|
||||||
|
|
||||||
`@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
|
`noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только
|
||||||
из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
|
из этого правила. Policy получает имя и единицы; подавление допустимо для
|
||||||
incidental value с объяснением. Обычный UI-текст не превращай в constant:
|
incidental value с объяснением. Обычный UI-текст не превращай в constant:
|
||||||
его owner — [i18n](ui.md#строки-и-валидация).
|
его owner — [i18n](ui.md#строки-и-валидация).
|
||||||
`no-restricted-syntax` запрещает inline machine codes и protocol values в production;
|
Semantic magic strings и props profile требуют review.
|
||||||
`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют
|
|
||||||
literal wire values. Новые vocabularies и props profile требуют review.
|
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
|
|||||||
+17
-78
@@ -11,17 +11,13 @@ Mantine component и общую тему. Для выпадающего выбо
|
|||||||
ограничения JSDOM решай в test setup, сохраняя production UI.
|
ограничения JSDOM решай в test setup, сохраняя production UI.
|
||||||
Семантический HTML для структуры страницы остаётся уместным.
|
Семантический HTML для структуры страницы остаётся уместным.
|
||||||
|
|
||||||
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
|
Icons ищи у существующего owner (например,
|
||||||
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
|
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx));
|
||||||
иконки из model сущности. Каждый SVG хранится цельным декларативным
|
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
|
||||||
компонентом: не разбивай path-строки и не собирай геометрию в runtime.
|
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
|
||||||
Русские category names из API — данные для нормализованного сопоставления,
|
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
|
||||||
не ключи реестра иконок; неизвестные/переименованные категории имеют fallback.
|
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
|
||||||
Известные названия банков принадлежат enum `BankName` в bank-card model;
|
components живут у feature/entity/page, даже при повторном использовании.
|
||||||
свободный ввод пользовательского названия остаётся строкой.
|
|
||||||
SVG декоративны по умолчанию; доступное имя action
|
|
||||||
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
|
|
||||||
и доступное имя. Приложение и Storybook подключают CSS пакета.
|
|
||||||
|
|
||||||
Wrapper нужен для проектной ответственности, а не ради нового имени
|
Wrapper нужен для проектной ответственности, а не ради нового имени
|
||||||
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
||||||
@@ -55,76 +51,19 @@ 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 получает готовые данные и именованные действия. Container вызывает VM;
|
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
|
||||||
VM владеет запросами, формой, состоянием, обработчиками и derived values.
|
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
|
||||||
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
|
image fallback) и formatter одного переданного значения. Группировка/доли
|
||||||
вызывает container; presentation hook не должен скрывать fetching во view.
|
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
|
||||||
Presentation VM для form-context/UI-only state может использоваться автономно
|
|
||||||
в каталоге: её граф зависимостей не достигает application data/API.
|
|
||||||
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
|
||||||
|
|
||||||
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
|
Отдельный state/section с самостоятельной responsibility выноси в flat
|
||||||
type imports, error helpers, router components и form context разрешены.
|
`components/*.view.tsx`. Небольшой tightly-coupled renderer оставляй рядом.
|
||||||
Транзитивный fetching через local presentation hook проверяется review;
|
Не создавай folder/barrel/test/CSS для каждого view и не дроби по line count.
|
||||||
Storybook дополнительно проверяет reachable graph. Новое исключение должно
|
Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage.tsx)
|
||||||
сохранять presentation-only responsibility.
|
и [его VM](../../src/pages/account-income/ui/AccountIncomePage.vm.ts).
|
||||||
|
|
||||||
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 выноси в
|
|
||||||
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
|
||||||
рядом; размер файла сам по себе не требует разделения.
|
|
||||||
Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx)
|
|
||||||
и [его VM](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts).
|
|
||||||
|
|
||||||
## Размещение файлов компонента
|
|
||||||
|
|
||||||
Правило действует в `src` и `packages`: компонент с сопутствующими файлами
|
|
||||||
(view, VM, types, CSS, tests, stories) размещай в папке `Component/`.
|
|
||||||
Сохраняй полные имена файлов: `Component.tsx`, `Component.view.tsx`,
|
|
||||||
`Component.test.tsx`. Создавай только нужные файлы; одиночный компонент
|
|
||||||
оставляй непосредственно в `ui`, `components` или source-каталоге пакета.
|
|
||||||
|
|
||||||
Внутренние части принадлежат папке родителя; для набора state/section views
|
|
||||||
используй её `components/`. Внутри него действует то же правило: отдельная
|
|
||||||
папка объединяет комплект файлов, одиночный view остаётся файлом.
|
|
||||||
Стили и хуки нескольких соседних компонентов остаются у ближайшего общего
|
|
||||||
владельца; не копируй их и не прячь внутрь одного потребителя.
|
|
||||||
|
|
||||||
Папка компонента не создаёт новую FSD-границу. Публичные exports остаются в
|
|
||||||
существующем `index.ts` slice/segment или entry пакета; внутри используй
|
|
||||||
прямые относительные imports без дополнительных barrels на каждый компонент.
|
|
||||||
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
|
||||||
|
|
||||||
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
|
||||||
лежит рядом со своим view, тестом и story. Формы используют Mantine
|
|
||||||
`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 показывай у поля;
|
||||||
@@ -159,7 +98,7 @@ Types, props, hooks и memoization — в [TypeScript/React](typescript-react.md
|
|||||||
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
|
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
|
||||||
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
|
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
|
||||||
пользовательских literal messages. Это mandatory semantic review: общего запрета
|
пользовательских literal messages. Это mandatory semantic review: общего запрета
|
||||||
непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и
|
непереведённых JSX-строк в Biome/TypeScript нет. Synthetic story fixtures и
|
||||||
описания историй допустимы; они не заменяют продуктовую локализацию.
|
описания историй допустимы; они не заменяют продуктовую локализацию.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|||||||
+7
-10
@@ -22,7 +22,13 @@
|
|||||||
!packages/ui/vite.config.ts
|
!packages/ui/vite.config.ts
|
||||||
!packages/ui/tsconfig.json
|
!packages/ui/tsconfig.json
|
||||||
!packages/ui/src/
|
!packages/ui/src/
|
||||||
!packages/ui/src/**
|
!packages/ui/src/theme.ts
|
||||||
|
!packages/ui/src/styles.css
|
||||||
|
!packages/ui/src/index.ts
|
||||||
|
!packages/ui/src/StateView.tsx
|
||||||
|
!packages/ui/src/LoadingSkeleton.tsx
|
||||||
|
!packages/ui/src/EntryCard.tsx
|
||||||
|
!packages/ui/src/EntryCard.module.css
|
||||||
!packages/icons/
|
!packages/icons/
|
||||||
!packages/icons/package.json
|
!packages/icons/package.json
|
||||||
!packages/icons/tsconfig.json
|
!packages/icons/tsconfig.json
|
||||||
@@ -34,12 +40,3 @@
|
|||||||
!packages/icons/src/svg/AddIcon.tsx
|
!packages/icons/src/svg/AddIcon.tsx
|
||||||
!packages/icons/src/svg/EditIcon.tsx
|
!packages/icons/src/svg/EditIcon.tsx
|
||||||
!packages/icons/src/svg/DeleteIcon.tsx
|
!packages/icons/src/svg/DeleteIcon.tsx
|
||||||
|
|
||||||
# Keep local dependencies, outputs and secrets out of allowed source trees.
|
|
||||||
**/node_modules
|
|
||||||
**/dist
|
|
||||||
**/coverage
|
|
||||||
**/storybook-static
|
|
||||||
**/.env*
|
|
||||||
**/*.pem
|
|
||||||
**/*.key
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Conventional commits
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, edited]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
commits:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- run: npm ci --ignore-scripts
|
||||||
|
- name: Check PR title and commits
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
@@ -30,8 +30,6 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
env:
|
env:
|
||||||
HUSKY: '0'
|
HUSKY: '0'
|
||||||
- name: Install Chromium and system dependencies
|
|
||||||
run: npx --no-install playwright install --with-deps chromium
|
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
env:
|
env:
|
||||||
KNIP_DISABLE_RAW_TRANSFER: '1'
|
KNIP_DISABLE_RAW_TRANSFER: '1'
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Frontend security
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 5 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Record checked revision
|
||||||
|
run: git rev-parse HEAD
|
||||||
|
- name: Scan history with Gitleaks 8.30.1
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
||||||
|
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
||||||
|
mkdir -p /tmp/gitleaks-bin
|
||||||
|
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
||||||
|
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Verify pre-commit secret blocking and redaction
|
||||||
|
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
|
||||||
|
- name: Audit runtime and development dependencies
|
||||||
|
run: npm audit --include=dev --audit-level=low
|
||||||
@@ -2,74 +2,12 @@ name: Frontend verify
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop, main]
|
branches:
|
||||||
|
- '**'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, edited]
|
|
||||||
schedule:
|
|
||||||
- cron: '17 5 * * *'
|
|
||||||
|
|
||||||
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
|
|
||||||
concurrency:
|
|
||||||
group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
commits:
|
|
||||||
if: gitea.event_name == 'pull_request'
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
- run: npm ci --ignore-scripts
|
|
||||||
- name: Check PR title and commits
|
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
|
||||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
|
||||||
|
|
||||||
security:
|
|
||||||
needs: commits
|
|
||||||
if: >-
|
|
||||||
!cancelled() &&
|
|
||||||
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Record checked revision
|
|
||||||
run: git rev-parse HEAD
|
|
||||||
- name: Scan history with Gitleaks 8.30.1
|
|
||||||
run: |
|
|
||||||
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
|
||||||
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
|
||||||
mkdir -p /tmp/gitleaks-bin
|
|
||||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
|
||||||
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
- name: Verify pre-commit secret blocking and redaction
|
|
||||||
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
|
|
||||||
- name: Audit runtime and development dependencies
|
|
||||||
run: npm audit --include=dev --audit-level=low
|
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
|
||||||
if: >-
|
|
||||||
!cancelled() && needs.security.result == 'success' &&
|
|
||||||
gitea.event_name != 'schedule'
|
|
||||||
timeout-minutes: 45
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
@@ -83,8 +21,6 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
env:
|
env:
|
||||||
HUSKY: '0'
|
HUSKY: '0'
|
||||||
- name: Install Chromium and system dependencies
|
|
||||||
run: npx --no-install playwright install --with-deps chromium
|
|
||||||
- name: Run all frontend gates
|
- name: Run all frontend gates
|
||||||
run: npm run verify
|
run: npm run verify
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ dist
|
|||||||
storybook-static
|
storybook-static
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
.vitest
|
|
||||||
.scannerwork
|
.scannerwork
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ module.exports = {
|
|||||||
forbidden: [
|
forbidden: [
|
||||||
...base.forbidden,
|
...base.forbidden,
|
||||||
{
|
{
|
||||||
from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" },
|
|
||||||
name: "stories-must-not-reach-application-data",
|
name: "stories-must-not-reach-application-data",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
|
||||||
to: {
|
to: {
|
||||||
path: [
|
path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/[^/]+Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
|
||||||
"^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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
addons: ["@storybook/addon-docs"],
|
|
||||||
core: { disableTelemetry: true },
|
|
||||||
framework: "@storybook/react-vite",
|
|
||||||
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
|
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
|
||||||
|
addons: ["@storybook/addon-docs"],
|
||||||
|
framework: "@storybook/react-vite",
|
||||||
|
core: { disableTelemetry: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+15
-19
@@ -1,34 +1,39 @@
|
|||||||
import "@mantine/charts/styles.css";
|
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/charts/styles.css";
|
||||||
import "@mantine/dates/styles.css";
|
import "@mantine/dates/styles.css";
|
||||||
import "@wealthledger/icons/styles.css";
|
|
||||||
import "@wealthledger/ui/styles.css";
|
import "@wealthledger/ui/styles.css";
|
||||||
import "dayjs/locale/ru";
|
import "dayjs/locale/ru";
|
||||||
|
|
||||||
import type { Preview } from "@storybook/react-vite";
|
|
||||||
|
|
||||||
import "../src/shared/i18n";
|
import "../src/shared/i18n";
|
||||||
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { DatesProvider } from "@mantine/dates";
|
import { DatesProvider } from "@mantine/dates";
|
||||||
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
|
|
||||||
const colorSchemeManager = {
|
const colorSchemeManager = {
|
||||||
clear: () => {},
|
|
||||||
get: () => "light" as const,
|
get: () => "light" as const,
|
||||||
set: () => {},
|
set: () => {},
|
||||||
|
clear: () => {},
|
||||||
subscribe: () => {},
|
subscribe: () => {},
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
options: {
|
||||||
|
storySort: {
|
||||||
|
order: ["Design System", ["Foundations", "Components"], "Product"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controls: { expanded: true },
|
||||||
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
Story => (
|
(Story) => (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
colorSchemeManager={colorSchemeManager}
|
|
||||||
cssVariablesResolver={cssVariablesResolver}
|
cssVariablesResolver={cssVariablesResolver}
|
||||||
forceColorScheme="light"
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
forceColorScheme="light"
|
||||||
|
colorSchemeManager={colorSchemeManager}
|
||||||
>
|
>
|
||||||
<DatesProvider settings={{ locale: "ru" }}>
|
<DatesProvider settings={{ locale: "ru" }}>
|
||||||
<Story />
|
<Story />
|
||||||
@@ -36,15 +41,6 @@ const preview: Preview = {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
parameters: {
|
|
||||||
controls: { expanded: true },
|
|
||||||
layout: "padded",
|
|
||||||
options: {
|
|
||||||
storySort: {
|
|
||||||
order: ["Design System", ["Foundations", "Components"], "Product"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто
|
|||||||
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
|
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
|
||||||
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
|
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
|
||||||
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
|
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
|
||||||
| Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs |
|
| Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs |
|
||||||
|
|||||||
@@ -1,90 +1,5 @@
|
|||||||
# 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)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* show persisted investment data and sync state ([4e5f564](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/4e5f564fa1d583013fb3e7febdb3baa9737446b4))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **api:** update validation and conflict response contract ([db184c8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/db184c8caec01609e8a3df3b16097ce634e5eff0))
|
|
||||||
* **auth:** isolate session races and financial caches ([a209d71](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a209d715892862bb6d48084df0c84a10b8d56bf1))
|
|
||||||
* **ci:** resume verification of immutable release images ([6a87379](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a87379813ba1d4002779b23b2a65446b4d10258))
|
|
||||||
* **ui:** improve financial text contrast ([c86be7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c86be7bf73d0141e0606d259ada6453c0d8e3dec))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
* colocate frontend component files ([b987fd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b987fd8e780963dfe1dde0c714232697a52e0475))
|
|
||||||
* establish local frontend workspace boundaries ([85fcfe3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/85fcfe3d073f80e759dafb14281c9462c1d7410d))
|
|
||||||
|
|
||||||
## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20)
|
## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -75,68 +75,20 @@ Service worker кеширует только статическую оболоч
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvm use --silent
|
nvm use --silent
|
||||||
npm ci
|
|
||||||
npx --no-install playwright install chromium
|
|
||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||||
затем запускает strict TypeScript, ESLint и проверку его конфигурации,
|
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
||||||
один coverage-backed запуск Vitest,
|
|
||||||
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
||||||
|
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
|
||||||
`npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix`
|
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
||||||
применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
|
|
||||||
наборы всех установленных плагинов: TypeScript, React, Hooks, Refresh, Query,
|
|
||||||
Unicorn, import-x, SonarJS, RegExp, Security, no-unsanitized, Perfectionist, Stylistic,
|
|
||||||
Vitest, Testing Library, jest-dom и Storybook. Правила React, тестов и stories
|
|
||||||
применяются к соответствующим файлам; generated/build artifacts исключены.
|
|
||||||
|
|
||||||
Stylistic отвечает за форматирование JS/TS/TSX, Perfectionist — за сортировку.
|
|
||||||
Сохраняются табы, двойные кавычки и точки с запятой. Biome удалён; Prettier
|
|
||||||
не используется. CSS, JSON и Markdown не форматируются через ESLint.
|
|
||||||
`lint-staged` исправляет JS/TS/TSX перед commit. `lint:eslint` сначала собирает
|
|
||||||
private packages, чтобы анализ типов использовал актуальные declarations.
|
|
||||||
`npm run test:lint` проверяет действующие presets и ошибочные примеры.
|
|
||||||
`no-unsanitized` проверяет опасные DOM HTML-присваивания и вызовы;
|
|
||||||
React `dangerouslySetInnerHTML` отдельно проверяется React-плагином.
|
|
||||||
|
|
||||||
Точечные исключения в исходниках объясняют контракты и ограничения статического
|
|
||||||
анализа; изменение nullable API fields, query keys или порядка race-операций
|
|
||||||
ради линтера недопустимо. Архитектуру проверяет dependency-cruiser, поведение —
|
|
||||||
Vitest/MSW и Chromium. Линтеры не заменяют semantic/security review.
|
|
||||||
`jsx-a11y` без поддержки ESLint 10 остаётся отложенным в
|
|
||||||
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
|
|
||||||
|
|
||||||
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
|
|
||||||
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
|
|
||||||
и его заглушки layout API. Только браузерная проверка:
|
|
||||||
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
|
|
||||||
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
|
|
||||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
|
||||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
|
||||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
|
||||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
|
||||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
|
||||||
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
|
|
||||||
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
|
||||||
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
||||||
исключений frontend нет. Локально можно выполнить те же команды:
|
исключений frontend нет. Локально можно выполнить те же команды:
|
||||||
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
|
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
|
||||||
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
|
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
|
||||||
проверка секретов выполняется локально и не выводит их значения.
|
проверка секретов выполняется локально и не выводит их значения.
|
||||||
|
|
||||||
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
|
||||||
workflow в статус. При внедрении администратор должен заменить required checks
|
|
||||||
для защищённых веток на `Frontend verify / commits (pull_request)`,
|
|
||||||
`Frontend verify / security (pull_request)` и
|
|
||||||
`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы,
|
|
||||||
если они требовались. Все три PR-проверки должны оставаться обязательными.
|
|
||||||
Публикация образов по тегам остаётся в `image.yml`.
|
|
||||||
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
|
|
||||||
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
|
|
||||||
runners. Конфигурация runner управляется вне этого репозитория.
|
|
||||||
|
|
||||||
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
|
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
|
||||||
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
|
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
|
||||||
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
|
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
|
||||||
@@ -262,14 +214,14 @@ Library build использует отдельный Vite config без app plu
|
|||||||
Начальная версия manifest взята из приложения; отдельный release-процесс
|
Начальная версия manifest взята из приложения; отдельный release-процесс
|
||||||
и публикация не настроены.
|
и публикация не настроены.
|
||||||
|
|
||||||
Private `@wealthledger/icons` содержит все SVG приложения: действия,
|
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
|
||||||
навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
|
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
|
||||||
ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
|
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
|
||||||
подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
|
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
|
||||||
иконок. Category/kind mapping остаётся в model `entities/bank-card`.
|
цвет наследуется через `currentColor`, SVG props можно передать явно.
|
||||||
|
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
|
||||||
Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
|
доступное имя задавайте кнопке. Standalone пример:
|
||||||
`strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
|
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`.
|
||||||
имя принадлежит действию. Для самостоятельного изображения передайте
|
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
|
||||||
`aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
|
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
|
||||||
Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`.
|
Restore/category SVG и предметный BankOperationIcon остаются в приложении.
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# Security-плагины ESLint
|
|
||||||
|
|
||||||
Проверено 28 сентября 2026 года для frontend: ESLint 10.11.0,
|
|
||||||
TypeScript и React 19. Рекомендация — добавить только
|
|
||||||
`eslint-plugin-no-unsanitized@4.1.5` с полным `configs.recommended`.
|
|
||||||
Решение реализовано: пакет добавлен в devDependencies, полный recommended
|
|
||||||
подключён в ESLint. Ниже сохранены результаты исследования до установки.
|
|
||||||
|
|
||||||
## Все пакеты из раздела Security
|
|
||||||
|
|
||||||
Источник списка: [awesome-eslint / Security](https://github.com/dustinspecker/awesome-eslint#security).
|
|
||||||
Версии и даты ниже — опубликованные `latest` из npm на момент проверки.
|
|
||||||
Широкий peer range сам по себе не доказывает работоспособность.
|
|
||||||
|
|
||||||
| Пакет | Версия; публикация | Заявленный peer ESLint | Решение для frontend |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| [no-unsanitized](https://github.com/mozilla/eslint-plugin-no-unsanitized) | [4.1.5](https://registry.npmjs.org/eslint-plugin-no-unsanitized/4.1.5); 2026-02-19 | `^9 \|\| ^10` | Добавить: проверяет опасные присваивания и вызовы native DOM HTML API. Flat recommended включает `property` и `method` как errors. |
|
|
||||||
| [no-secrets](https://github.com/nickdeis/eslint-plugin-no-secrets) | [2.3.3](https://registry.npmjs.org/eslint-plugin-no-secrets/2.3.3); 2026-03-01 | `>=5` | Не добавлять: поиск секретов по entropy/patterns существенно пересекается с существующими Gitleaks и SonarJS. Flat config поддерживается; отсутствие рекомендации связано с дублированием. |
|
|
||||||
| [pii](https://github.com/shiva-hack/eslint-plugin-pii) | [1.0.2](https://registry.npmjs.org/eslint-plugin-pii/1.0.2); 2021-04-13 | `>=4.19.1` | Не добавлять: правило падает на ESLint 10. Проверяет email/даты рождения/IP/телефоны в литералах и комментариях, а не потоки персональных данных; потребует исключений для тестовых данных. |
|
|
||||||
| [pg](https://github.com/interlace-collie/eslint/tree/main/packages/eslint-plugin-pg) | [1.4.14](https://registry.npmjs.org/eslint-plugin-pg/1.4.14); 2026-08-06 | `^8.0.0 \|\| ^9.0.0 \|\| ^10.0.0` | Не добавлять: предназначен для Node.js с `pg`, которого во frontend нет. Пакет deprecated и переименован в `eslint-plugin-postgresql-security`; старое имя больше не обновляется. |
|
|
||||||
| [security](https://github.com/eslint-community/eslint-plugin-security) | [4.1.0](https://registry.npmjs.org/eslint-plugin-security/4.1.0); 2026-09-27 | Не указан | Уже установлен с recommended. Ориентирован главным образом на Node.js; не заменяет проверки DOM. |
|
|
||||||
| [xss](https://github.com/Rantanen/eslint-plugin-xss) | [0.1.12](https://registry.npmjs.org/eslint-plugin-xss/0.1.12); 2022-06-27 | Не указан | Не добавлять: правило падает на ESLint 10. Проверяет HTML-конкатенации/соглашения об именах и присваивания `location.href`; это не специализированная проверка React/TSX. |
|
|
||||||
|
|
||||||
## Что добавляет no-unsanitized
|
|
||||||
|
|
||||||
[Правило property](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/property.md)
|
|
||||||
проверяет присваивания `innerHTML`/`outerHTML`;
|
|
||||||
[method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/method.md)
|
|
||||||
проверяет, в частности, `insertAdjacentHTML`, `document.write` и `document.writeln`.
|
|
||||||
В [исходнике method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/lib/rules/method.js)
|
|
||||||
также есть проверки `createContextualFragment`, `setHTMLUnsafe` и dynamic import.
|
|
||||||
Следовательно, перед включением нужно прогнать весь lint, а не только HTML-примеры.
|
|
||||||
|
|
||||||
До подключения no-unsanitized [eslint.config.mjs](eslint.config.mjs) уже включал
|
|
||||||
React-проверку `dom-no-dangerously-set-innerhtml` и security-правила SonarJS.
|
|
||||||
Предварительная проверка через локальный `ESLint.lintText` показала пробел:
|
|
||||||
функция с `element.innerHTML = html` не получала security-диагностику,
|
|
||||||
тогда как JSX `dangerouslySetInnerHTML` получал
|
|
||||||
диагностику React. Это различие в покрытии, не обнаруженная уязвимость приложения:
|
|
||||||
таких production DOM-присваиваний при поиске не найдено.
|
|
||||||
|
|
||||||
Плагин понимает TS-синтаксис через TypeScript parser, включая `as`-выражения,
|
|
||||||
но не использует TypeScript type checker. Он не доказывает безопасность всех
|
|
||||||
потоков данных. У версии 4.1.5 нет runtime dependencies; Prettier указан только
|
|
||||||
в devDependencies самого плагина и при его обычной установке потребителем
|
|
||||||
не устанавливается.
|
|
||||||
|
|
||||||
## Проверка совместимости без установки
|
|
||||||
|
|
||||||
Опубликованные npm tarballs прочитаны в `/tmp`; использованы локальные
|
|
||||||
ESLint 10.11.0 и `@typescript-eslint/parser`, без lifecycle-скриптов пакетов.
|
|
||||||
Через `Linter.verify` с recommended no-unsanitized проверены:
|
|
||||||
|
|
||||||
- `innerHTML`/`outerHTML` с параметром `html: string` — обе ошибки найдены;
|
|
||||||
- `insertAdjacentHTML`/`document.write`/`document.writeln` — все три ошибки найдены;
|
|
||||||
- `innerHTML = html as string` — ошибка найдена;
|
|
||||||
- безопасный `textContent` и обычная TSX-интерполяция — без ошибок.
|
|
||||||
|
|
||||||
Отдельный запуск реальных правил `pii/no-email` и
|
|
||||||
`xss/no-location-href-assign` завершился
|
|
||||||
`TypeError: context.getSourceCode is not a function` на ESLint 10.
|
|
||||||
Это подтверждает несовместимость проверенных версий, несмотря на широкий
|
|
||||||
или отсутствующий peer range. Обходы через legacy adapters не предлагаются.
|
|
||||||
|
|
||||||
Поиск секретов уже выполняют [security workflow](.gitea/workflows/security.yml)
|
|
||||||
и pre-commit Gitleaks; SonarJS дополнительно проверяет hardcoded secrets.
|
|
||||||
Добавление ещё одного secret-scanner в ESLint сейчас не оправдано.
|
|
||||||
|
|
||||||
Активацию обоих правил и примеры опасных DOM-вставок проверяет
|
|
||||||
`npm run test:lint`; интеграцию зависимости — полный `npm run verify`.
|
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!dist",
|
||||||
|
"!packages/*/dist",
|
||||||
|
"!storybook-static",
|
||||||
|
"!src/shared/api/generated",
|
||||||
|
"!src/shared/api/spec",
|
||||||
|
"!coverage",
|
||||||
|
"!node_modules"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended",
|
||||||
|
"nursery": {
|
||||||
|
"useReactFunctionComponentDefinition": {
|
||||||
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"namedComponents": "arrowFunction"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "error",
|
||||||
|
"useExportsLast": "error",
|
||||||
|
"noMagicNumbers": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
".storybook/main.ts",
|
||||||
|
".storybook/preview.tsx",
|
||||||
|
"packages/*/vite.config.ts"
|
||||||
|
],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/*.stories.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off",
|
||||||
|
"useExportsLast": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"**/*.stories.tsx",
|
||||||
|
"src/test/**"
|
||||||
|
],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noMagicNumbers": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+15
-40
@@ -1,88 +1,63 @@
|
|||||||
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: {},
|
|
||||||
name: "no-unresolved",
|
|
||||||
severity: "error",
|
|
||||||
to: { couldNotResolve: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: { path: "^packages/" },
|
|
||||||
name: "packages-must-not-depend-on-app",
|
name: "packages-must-not-depend-on-app",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
from: { path: "^packages/" },
|
||||||
to: { path: "^src/" },
|
to: { path: "^src/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: {},
|
|
||||||
name: "no-circular",
|
name: "no-circular",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
from: {},
|
||||||
to: { circular: true },
|
to: { circular: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: {
|
|
||||||
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
|
|
||||||
},
|
|
||||||
name: "slices-must-use-public-api",
|
name: "slices-must-use-public-api",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
from: {
|
||||||
|
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
|
||||||
|
},
|
||||||
to: {
|
to: {
|
||||||
path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
|
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
|
||||||
pathNot: "^src/$1/$2/",
|
pathNot: "^src/$1/$2/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
||||||
|
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
|
||||||
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/shared/" },
|
|
||||||
name: "shared-must-not-depend-on-layers",
|
name: "shared-must-not-depend-on-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(entities|features|widgets|pages|app)/" },
|
from: { path: "^src/shared/" },
|
||||||
|
to: { path: "^src/(entities|features|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/entities/" },
|
|
||||||
name: "entities-must-not-depend-on-higher-layers",
|
name: "entities-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(features|widgets|pages|app)/" },
|
from: { path: "^src/entities/" },
|
||||||
|
to: { path: "^src/(features|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/features/" },
|
|
||||||
name: "features-must-not-depend-on-higher-layers",
|
name: "features-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(widgets|pages|app)/" },
|
from: { path: "^src/features/" },
|
||||||
},
|
|
||||||
{
|
|
||||||
from: { path: "^src/widgets/" },
|
|
||||||
name: "widgets-must-not-depend-on-higher-layers",
|
|
||||||
severity: "error",
|
|
||||||
to: { path: "^src/(pages|app)/" },
|
to: { path: "^src/(pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/pages/" },
|
|
||||||
name: "pages-must-not-depend-on-app",
|
name: "pages-must-not-depend-on-app",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
from: { path: "^src/pages/" },
|
||||||
to: { path: "^src/app/" },
|
to: { path: "^src/app/" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
options: {
|
options: {
|
||||||
doNotFollow: { path: "node_modules" },
|
|
||||||
enhancedResolveOptions: {
|
|
||||||
conditionNames: ["import", "require", "node", "default"],
|
|
||||||
exportsFields: ["exports"],
|
|
||||||
},
|
|
||||||
exclude: "^packages/[^/]+/dist/",
|
exclude: "^packages/[^/]+/dist/",
|
||||||
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
|
doNotFollow: { path: "node_modules" },
|
||||||
|
tsConfig: { fileName: "tsconfig.app.json" },
|
||||||
tsPreCompilationDeps: true,
|
tsPreCompilationDeps: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
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 } };
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import react from "@eslint-react/eslint-plugin";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import stylistic from "@stylistic/eslint-plugin";
|
|
||||||
import query from "@tanstack/eslint-plugin-query";
|
|
||||||
import vitest from "@vitest/eslint-plugin";
|
|
||||||
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
|
|
||||||
import { importX } from "eslint-plugin-import-x";
|
|
||||||
import jestDom from "eslint-plugin-jest-dom";
|
|
||||||
import noUnsanitized from "eslint-plugin-no-unsanitized";
|
|
||||||
import perfectionist from "eslint-plugin-perfectionist";
|
|
||||||
import hooks from "eslint-plugin-react-hooks";
|
|
||||||
import refresh from "eslint-plugin-react-refresh";
|
|
||||||
import regexp from "eslint-plugin-regexp";
|
|
||||||
import security from "eslint-plugin-security";
|
|
||||||
import sonarjs from "eslint-plugin-sonarjs";
|
|
||||||
import storybook from "eslint-plugin-storybook";
|
|
||||||
import testingLibrary from "eslint-plugin-testing-library";
|
|
||||||
import unicorn from "eslint-plugin-unicorn";
|
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import globals from "globals";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
import frontend from "./eslint-rules.mjs";
|
|
||||||
|
|
||||||
export default defineConfig(
|
|
||||||
{
|
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
|
||||||
plugins: { frontend },
|
|
||||||
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
|
|
||||||
},
|
|
||||||
globalIgnores([
|
|
||||||
"**/dist/**",
|
|
||||||
"dist-ssr/**",
|
|
||||||
"storybook-static/**",
|
|
||||||
"coverage/**",
|
|
||||||
".vitest/**",
|
|
||||||
".scannerwork/**",
|
|
||||||
"src/shared/api/generated/**",
|
|
||||||
"src/shared/api/spec/**",
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
importX.flatConfigs.recommended,
|
|
||||||
unicorn.configs.recommended,
|
|
||||||
sonarjs.configs.recommended,
|
|
||||||
regexp.configs.recommended,
|
|
||||||
security.configs.recommended,
|
|
||||||
noUnsanitized.configs.recommended,
|
|
||||||
perfectionist.configs["recommended-natural"],
|
|
||||||
stylistic.configs.customize({
|
|
||||||
indent: "tab",
|
|
||||||
jsx: true,
|
|
||||||
quotes: "double",
|
|
||||||
semi: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
|
||||||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
|
||||||
rules: {
|
|
||||||
// A comma disambiguates generic arrow functions from JSX in TSX.
|
|
||||||
"@stylistic/comma-dangle": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
arrays: "always-multiline",
|
|
||||||
enums: "always-multiline",
|
|
||||||
exports: "always-multiline",
|
|
||||||
functions: "always-multiline",
|
|
||||||
generics: "ignore",
|
|
||||||
imports: "always-multiline",
|
|
||||||
objects: "always-multiline",
|
|
||||||
tuples: "always-multiline",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Keep text/interpolation together: splitting can change rendered whitespace.
|
|
||||||
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
|
||||||
"@stylistic/max-len": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
code: 80,
|
|
||||||
ignoreComments: true,
|
|
||||||
ignoreRegExpLiterals: true,
|
|
||||||
ignoreStrings: true,
|
|
||||||
ignoreTemplateLiterals: true,
|
|
||||||
ignoreTrailingComments: true,
|
|
||||||
ignoreUrls: true,
|
|
||||||
tabWidth: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
|
||||||
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
|
||||||
"@stylistic/operator-linebreak": ["error", "before"],
|
|
||||||
"@stylistic/padding-line-between-statements": [
|
|
||||||
"error",
|
|
||||||
{ blankLine: "always", next: "return", prev: "*" },
|
|
||||||
{
|
|
||||||
blankLine: "always",
|
|
||||||
next: "*",
|
|
||||||
prev: ["multiline-const", "multiline-let", "block-like", "type"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
blankLine: "always",
|
|
||||||
next: ["multiline-const", "multiline-let", "block-like", "type"],
|
|
||||||
prev: "*",
|
|
||||||
},
|
|
||||||
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
|
|
||||||
],
|
|
||||||
"no-nested-ternary": "error",
|
|
||||||
"perfectionist/sort-classes": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
groups: ["static-method", "property", "method", "unknown"],
|
|
||||||
type: "natural",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Preserve the repository's component, hook and config file naming conventions.
|
|
||||||
"unicorn/filename-case": [
|
|
||||||
"error",
|
|
||||||
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
|
|
||||||
],
|
|
||||||
// React uses Props/Ref names, including the recommended React ref-name rule.
|
|
||||||
"unicorn/name-replacements": [
|
|
||||||
"error",
|
|
||||||
{ replacements: { props: false, ref: false } },
|
|
||||||
],
|
|
||||||
// API DTOs, React refs and Mantine use null as part of their contracts.
|
|
||||||
"unicorn/no-null": "off",
|
|
||||||
"unicorn/prefer-ternary": ["error", "only-single-line"],
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
"import-x/resolver-next": [
|
|
||||||
createTypeScriptImportResolver({ project: "tsconfig.json" }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
|
|
||||||
languageOptions: { globals: globals.node },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: [
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
importX.flatConfigs.typescript,
|
|
||||||
],
|
|
||||||
files: ["**/*.{ts,tsx}"],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/consistent-type-imports": "error",
|
|
||||||
// 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-non-null-assertion": "error",
|
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: [
|
|
||||||
react.configs["recommended-type-checked"],
|
|
||||||
hooks.configs.flat.recommended,
|
|
||||||
query.configs["flat/recommended"],
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
"src/**/*.{ts,tsx}",
|
|
||||||
"packages/*/src/**/*.{ts,tsx}",
|
|
||||||
".storybook/preview.tsx",
|
|
||||||
],
|
|
||||||
languageOptions: { globals: globals.browser },
|
|
||||||
rules: {
|
|
||||||
"import-x/exports-last": "error",
|
|
||||||
"import-x/no-default-export": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: [refresh.configs.vite],
|
|
||||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
|
||||||
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/**/*.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}"],
|
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-magic-numbers": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
|
|
||||||
ignoreArrayIndexes: true,
|
|
||||||
ignoreDefaultValues: true,
|
|
||||||
ignoreNumericLiteralTypes: true,
|
|
||||||
ignoreTypeIndexes: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: [vitest.configs.recommended],
|
|
||||||
files: ["**/*.test.{ts,tsx}"],
|
|
||||||
rules: {
|
|
||||||
"vitest/no-focused-tests": "error",
|
|
||||||
"vitest/valid-expect": ["error", { maxArgs: 2 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: [
|
|
||||||
testingLibrary.configs["flat/react"],
|
|
||||||
jestDom.configs["flat/recommended"],
|
|
||||||
],
|
|
||||||
files: ["**/*.test.tsx"],
|
|
||||||
},
|
|
||||||
storybook.configs["flat/recommended"],
|
|
||||||
{
|
|
||||||
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
|
||||||
rules: {
|
|
||||||
"import-x/exports-last": "off",
|
|
||||||
"import-x/no-default-export": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
|
||||||
"no-restricted-syntax": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
|
|
||||||
selector:
|
|
||||||
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Name the derived prop value before JSX.",
|
|
||||||
selector:
|
|
||||||
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Use the owning enum member for machine codes and protocol values.",
|
|
||||||
selector:
|
|
||||||
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Use the owning enum member instead of a template machine code.",
|
|
||||||
selector:
|
|
||||||
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Use an enum member instead of an inline code or protocol value.",
|
|
||||||
selector:
|
|
||||||
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Use an enum member instead of constructing a code or protocol value.",
|
|
||||||
selector:
|
|
||||||
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Use a computed enum member as the protocol mapping key.",
|
|
||||||
selector:
|
|
||||||
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Compare codes with their enum members, not string literals.",
|
|
||||||
selector:
|
|
||||||
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Generated
+1287
-2955
File diff suppressed because it is too large
Load Diff
+6
-38
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.21.0"
|
"node": "24.21.0"
|
||||||
@@ -13,28 +13,25 @@
|
|||||||
"preview:catalog": "vite preview --outDir storybook-static",
|
"preview:catalog": "vite preview --outDir storybook-static",
|
||||||
"typecheck": "npm run build:packages && tsc -b --noEmit",
|
"typecheck": "npm run build:packages && tsc -b --noEmit",
|
||||||
"build": "npm run build:packages && tsc -b && vite build",
|
"build": "npm run build:packages && tsc -b && vite build",
|
||||||
"lint": "npm run lint:eslint",
|
"lint": "biome check .",
|
||||||
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
|
|
||||||
"test": "npm run build:packages && vitest",
|
"test": "npm run build:packages && vitest",
|
||||||
"test:run": "npm run build:packages && vitest run",
|
"test:run": "npm run build:packages && vitest run",
|
||||||
"test:coverage": "npm run build:packages && vitest run --coverage",
|
"test:coverage": "npm run build:packages && vitest run --coverage",
|
||||||
"test:lint": "node --test scripts/test-eslint.mjs",
|
|
||||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "npm run build:packages && knip",
|
"knip": "npm run build:packages && knip",
|
||||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run build:packages && npm run openapi:check && 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",
|
"verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||||
"release:preview": "release-it --dry-run --ci",
|
"release:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint",
|
"commitlint": "commitlint",
|
||||||
"build:packages": "npm run build --workspaces --if-present",
|
"build:packages": "npm run build --workspaces --if-present"
|
||||||
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
|
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
@@ -42,7 +39,6 @@
|
|||||||
"@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",
|
||||||
@@ -63,64 +59,36 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "8.0.5",
|
"@babel/core": "8.0.5",
|
||||||
|
"@biomejs/biome": "2.5.13",
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.2",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.2",
|
||||||
"@eslint-react/eslint-plugin": "5.21.0",
|
|
||||||
"@eslint/js": "10.0.1",
|
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.0",
|
||||||
"@rolldown/plugin-babel": "0.2.4",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"@storybook/addon-docs": "10.6.0",
|
"@storybook/addon-docs": "10.6.0",
|
||||||
"@storybook/react-vite": "10.6.0",
|
"@storybook/react-vite": "10.6.0",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
|
||||||
"@tanstack/eslint-plugin-query": "5.104.0",
|
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.13.4",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/browser-playwright": "5.0.0",
|
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"@vitest/eslint-plugin": "1.6.27",
|
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
"dependency-cruiser": "18.2.0",
|
"dependency-cruiser": "18.2.0",
|
||||||
"eslint": "10.11.0",
|
|
||||||
"eslint-import-resolver-typescript": "4.4.5",
|
|
||||||
"eslint-plugin-import-x": "4.17.1",
|
|
||||||
"eslint-plugin-jest-dom": "5.10.1",
|
|
||||||
"eslint-plugin-no-unsanitized": "4.1.5",
|
|
||||||
"eslint-plugin-perfectionist": "5.12.1",
|
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
|
||||||
"eslint-plugin-react-refresh": "0.5.7",
|
|
||||||
"eslint-plugin-regexp": "3.3.1",
|
|
||||||
"eslint-plugin-security": "4.1.0",
|
|
||||||
"eslint-plugin-sonarjs": "4.2.1",
|
|
||||||
"eslint-plugin-storybook": "10.6.0",
|
|
||||||
"eslint-plugin-testing-library": "7.16.2",
|
|
||||||
"eslint-plugin-unicorn": "76.0.0",
|
|
||||||
"globals": "17.12.0",
|
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jsdom": "30.0.1",
|
"jsdom": "30.0.1",
|
||||||
"knip": "6.35.1",
|
"knip": "6.35.1",
|
||||||
"lint-staged": "17.5.1",
|
"lint-staged": "17.5.1",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
"playwright": "1.63.0",
|
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.70.1",
|
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.0",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.0"
|
||||||
},
|
},
|
||||||
"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
|
||||||
|
|||||||
@@ -10,12 +10,9 @@
|
|||||||
".": {
|
".": {
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
title: "Design System/Icons",
|
||||||
component: AddIcon,
|
component: AddIcon,
|
||||||
|
tags: ["autodocs"],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -12,26 +13,21 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
|
||||||
title: "Design System/Icons",
|
|
||||||
} satisfies Meta<typeof AddIcon>;
|
} satisfies Meta<typeof AddIcon>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Decorative: Story = {
|
export const Decorative: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
|
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
|
||||||
<button aria-label="Добавить пример" type="button">
|
<button type="button" aria-label="Добавить пример">
|
||||||
<AddIcon size={20} strokeWidth={2} />
|
<AddIcon size={20} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" aria-label="Изменить пример">
|
||||||
<button aria-label="Изменить пример" type="button">
|
|
||||||
<EditIcon size={18} />
|
<EditIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" aria-label="Удалить пример">
|
||||||
<button aria-label="Удалить пример" type="button">
|
|
||||||
<DeleteIcon size={18} />
|
<DeleteIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,12 +36,10 @@ export const Decorative: Story = {
|
|||||||
|
|
||||||
export const Meaningful: Story = {
|
export const Meaningful: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
|
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
|
||||||
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
|
<AddIcon aria-hidden={false} role="img" aria-label="Добавление" />
|
||||||
|
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />
|
||||||
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
|
<DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" />
|
||||||
|
|
||||||
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
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])(
|
||||||
"keeps decoration hidden and exposes explicitly named images: %s",
|
"keeps decoration hidden and exposes explicitly named images: %s",
|
||||||
(Icon) => {
|
(Icon) => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<button aria-label="Действие" type="button">
|
<button type="button" aria-label="Действие">
|
||||||
<Icon />
|
<Icon />
|
||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
|
screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
|
||||||
).toHaveAttribute("focusable", "false");
|
).toHaveAttribute("focusable", "false");
|
||||||
rerender(
|
rerender(
|
||||||
<Icon
|
<Icon
|
||||||
aria-hidden={false}
|
aria-hidden={false}
|
||||||
aria-label="Пример"
|
|
||||||
role="img"
|
role="img"
|
||||||
|
aria-label="Пример"
|
||||||
size={18}
|
size={18}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>,
|
/>,
|
||||||
@@ -34,16 +31,4 @@ 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,72 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
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";
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import type { IconProps } from "../types";
|
import type { IconProps } from "../types";
|
||||||
|
|
||||||
const AddIcon: FC<IconProps> = (props) => {
|
const AddIcon: FC<IconProps> = (props) => {
|
||||||
const { size = "24", ...svgProps } = props;
|
const { size = "24", ...svgProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={size}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={size}
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
{...svgProps}
|
{...svgProps}
|
||||||
>
|
>
|
||||||
<path d="M12 5v14 M5 12h14" />
|
<path d="M12 5v14 M5 12h14" />
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,22 +1,20 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import type { IconProps } from "../types";
|
import type { IconProps } from "../types";
|
||||||
|
|
||||||
const DeleteIcon: FC<IconProps> = (props) => {
|
const DeleteIcon: FC<IconProps> = (props) => {
|
||||||
const { size = "24", ...svgProps } = props;
|
const { size = "24", ...svgProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
width={size}
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={size}
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="1.5"
|
aria-hidden="true"
|
||||||
viewBox="0 0 24 24"
|
focusable="false"
|
||||||
width={size}
|
|
||||||
{...svgProps}
|
{...svgProps}
|
||||||
>
|
>
|
||||||
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
|
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import type { IconProps } from "../types";
|
import type { IconProps } from "../types";
|
||||||
|
|
||||||
const EditIcon: FC<IconProps> = (props) => {
|
const EditIcon: FC<IconProps> = (props) => {
|
||||||
const { size = "24", ...svgProps } = props;
|
const { size = "24", ...svgProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
width={size}
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={size}
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="1.5"
|
aria-hidden="true"
|
||||||
viewBox="0 0 24 24"
|
focusable="false"
|
||||||
width={size}
|
|
||||||
{...svgProps}
|
{...svgProps}
|
||||||
>
|
>
|
||||||
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import type { IconProps } from "../types";
|
|
||||||
|
|
||||||
const WalletIcon: FC<IconProps> = (props) => {
|
|
||||||
const { size = "24", ...svgProps } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
height={size}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={size}
|
|
||||||
{...svgProps}
|
|
||||||
>
|
|
||||||
<rect height="15" rx="3" width="18" x="3" y="5" />
|
|
||||||
|
|
||||||
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { WalletIcon };
|
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"target": "ES2023",
|
||||||
"ES2023",
|
"lib": ["ES2023", "DOM"],
|
||||||
"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": [
|
"include": ["src/index.ts"]
|
||||||
"src/index.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] },
|
lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
|
||||||
rolldownOptions: { external: /^react(\/|$)/ },
|
rolldownOptions: { external: /^react(\/|$)/ },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
"muted": {
|
"muted": {
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
"shade": 7,
|
"shade": 6,
|
||||||
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
||||||
},
|
},
|
||||||
"border": {
|
"border": {
|
||||||
|
|||||||
@@ -93,13 +93,3 @@
|
|||||||
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%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-19
@@ -1,27 +1,27 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
|
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { fn } from "storybook/test";
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
title: "Design System/Components/EntryCard",
|
||||||
|
component: EntryCard,
|
||||||
|
tags: ["autodocs"],
|
||||||
args: {
|
args: {
|
||||||
|
title:
|
||||||
|
"Учебная запись с очень длинным названием для проверки узкого экрана",
|
||||||
|
metadata: "22.09.2026 · Синтетические данные",
|
||||||
|
initial: "У",
|
||||||
|
imageSrc: "data:image/png;base64,invalid",
|
||||||
|
value: "−1 250,00 ₽",
|
||||||
|
valueLabel: "Тестовое значение",
|
||||||
|
tone: "negative",
|
||||||
actions: (
|
actions: (
|
||||||
<Button onClick={fn()} size="compact-sm">
|
<Button size="compact-sm" onClick={fn()}>
|
||||||
Открыть пример
|
Открыть пример
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
imageSrc: "data:image/png;base64,invalid",
|
|
||||||
initial: "У",
|
|
||||||
metadata: "22.09.2026 · Синтетические данные",
|
|
||||||
title:
|
|
||||||
"Учебная запись с очень длинным названием для проверки узкого экрана",
|
|
||||||
tone: "negative",
|
|
||||||
value: "−1 250,00 ₽",
|
|
||||||
valueLabel: "Тестовое значение",
|
|
||||||
},
|
},
|
||||||
component: EntryCard,
|
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -30,14 +30,8 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
|
||||||
title: "Design System/Components/EntryCard",
|
|
||||||
} satisfies Meta<typeof EntryCard>;
|
} satisfies Meta<typeof EntryCard>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const LongContentAndFallback: Story = {};
|
export const LongContentAndFallback: Story = {};
|
||||||
|
|
||||||
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
|
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { assert, describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
|
|
||||||
describe("EntryCard", () => {
|
describe("EntryCard", () => {
|
||||||
it("does not leak a numeric zero from an absent action", () => {
|
it("keeps the fallback after an image fails", () => {
|
||||||
render(
|
|
||||||
<MantineProvider>
|
|
||||||
<EntryCard
|
|
||||||
actions={0}
|
|
||||||
initial="У"
|
|
||||||
metadata="Описание"
|
|
||||||
title="Пример"
|
|
||||||
tone="neutral"
|
|
||||||
value="—"
|
|
||||||
valueLabel="Неизвестно"
|
|
||||||
/>
|
|
||||||
</MantineProvider>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it("keeps a failed image fallback until a new source is supplied", () => {
|
|
||||||
const props = {
|
const props = {
|
||||||
imageSrc: "data:image/png;base64,invalid",
|
|
||||||
initial: "У",
|
|
||||||
metadata: "Описание",
|
|
||||||
title: "Пример",
|
title: "Пример",
|
||||||
tone: "neutral" as const,
|
metadata: "Описание",
|
||||||
|
initial: "У",
|
||||||
value: "—",
|
value: "—",
|
||||||
valueLabel: "Неизвестно",
|
valueLabel: "Неизвестно",
|
||||||
|
tone: "neutral" as const,
|
||||||
|
imageSrc: "data:image/png;base64,invalid",
|
||||||
};
|
};
|
||||||
|
const { container, rerender } = render(
|
||||||
const { rerender } = render(
|
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<EntryCard {...props} />
|
<EntryCard {...props} />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decorative image has no accessible name; trigger its actual failure event.
|
// Decorative image has no accessible name; trigger its actual failure event.
|
||||||
const image = screen.queryByAltText("");
|
const image = container.querySelector("img");
|
||||||
assert(image);
|
assert(image);
|
||||||
fireEvent.error(image);
|
fireEvent.error(image);
|
||||||
rerender(
|
rerender(
|
||||||
@@ -47,18 +28,8 @@ describe("EntryCard", () => {
|
|||||||
<EntryCard {...props} title="Обновлённый пример" />
|
<EntryCard {...props} title="Обновлённый пример" />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
expect(screen.queryByAltText("")).not.toBeInTheDocument();
|
expect(container.querySelector("img")).toBeNull();
|
||||||
expect(screen.getByText("У")).toBeInTheDocument();
|
expect(screen.getByText("У")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
||||||
rerender(
|
|
||||||
<MantineProvider>
|
|
||||||
<EntryCard {...props} imageSrc="/replacement-logo.png" />
|
|
||||||
</MantineProvider>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByAltText("")).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
"/replacement-logo.png",
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("У")).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,78 +1,66 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
|
||||||
|
|
||||||
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import classes from "./EntryCard.module.css";
|
import classes from "./EntryCard.module.css";
|
||||||
|
|
||||||
type EntryCardProps = {
|
type EntryCardProps = {
|
||||||
actions?: ReactNode;
|
|
||||||
color?: string;
|
|
||||||
details?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
imageSrc?: null | string;
|
|
||||||
initial: string;
|
|
||||||
metadata: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
tone: "negative"
|
metadata: string;
|
||||||
| "neutral"
|
details?: ReactNode;
|
||||||
| "positive";
|
actions?: ReactNode;
|
||||||
|
initial: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color?: string;
|
||||||
|
imageSrc?: string | null;
|
||||||
value: string;
|
value: string;
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
wrap?: boolean;
|
tone: "positive" | "negative" | "neutral";
|
||||||
};
|
};
|
||||||
|
|
||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
const surfaceStyle = props.color
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
? {
|
|
||||||
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}
|
||||||
data-wrap={props.wrap || undefined}
|
|
||||||
style={surfaceStyle}
|
|
||||||
withBorder
|
withBorder
|
||||||
|
style={
|
||||||
|
props.color
|
||||||
|
? {
|
||||||
|
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
radius="md"
|
radius="md"
|
||||||
size={40}
|
size={40}
|
||||||
src={props.imageSrc}
|
src={imageFailed ? null : props.imageSrc}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
>
|
>
|
||||||
{props.icon ?? props.initial}
|
{props.icon ?? props.initial}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<Stack className={classes.identity} gap={2}>
|
<Stack className={classes.identity} gap={2}>
|
||||||
<Text fw={700} lineClamp={titleLineClamp}>
|
<Text fw={700} lineClamp={1}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{props.metadata}
|
{props.metadata}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{props.details}
|
{props.details}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack className={classes.value} gap={2}>
|
<Stack className={classes.value} gap={2}>
|
||||||
<Text className={classes[props.tone]} fw={700}>
|
<Text className={classes[props.tone]} fw={700}>
|
||||||
{props.value}
|
{props.value}
|
||||||
</Text>
|
</Text>
|
||||||
|
<div className={props.actions ? classes.valueFooter : undefined}>
|
||||||
<div className={valueContainerClass}>
|
<Text
|
||||||
<Text className={valueLabelClass} size="sm">
|
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{props.valueLabel}
|
{props.valueLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
{props.actions && (
|
||||||
{Boolean(props.actions) && (
|
|
||||||
<div className={classes.actions}>{props.actions}</div>
|
<div className={classes.actions}>{props.actions}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
+7
-13
@@ -1,16 +1,16 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
|
|
||||||
import { Paper, Skeleton } from "@mantine/core";
|
import { Paper, Skeleton } from "@mantine/core";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
title: "Design System/Components/LoadingSkeleton",
|
||||||
|
component: LoadingSkeleton,
|
||||||
|
tags: ["autodocs"],
|
||||||
args: { label: "Загрузка содержимого" },
|
args: { label: "Загрузка содержимого" },
|
||||||
argTypes: {
|
argTypes: {
|
||||||
children: { table: { disable: true } },
|
|
||||||
label: { control: "text" },
|
label: { control: "text" },
|
||||||
|
children: { table: { disable: true } },
|
||||||
},
|
},
|
||||||
component: LoadingSkeleton,
|
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -19,23 +19,17 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: arguments_ => (
|
render: (args) => (
|
||||||
<Paper p="md" withBorder>
|
<Paper p="md" withBorder>
|
||||||
<LoadingSkeleton {...arguments_}>
|
<LoadingSkeleton {...args}>
|
||||||
<Skeleton h={24} w="60%" />
|
<Skeleton h={24} w="60%" />
|
||||||
|
|
||||||
<Skeleton h={72} />
|
<Skeleton h={72} />
|
||||||
|
|
||||||
<Skeleton h={16} w="80%" />
|
<Skeleton h={16} w="80%" />
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
tags: ["autodocs"],
|
|
||||||
title: "Design System/Components/LoadingSkeleton",
|
|
||||||
} satisfies Meta<typeof LoadingSkeleton>;
|
} satisfies Meta<typeof LoadingSkeleton>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Initial: Story = { name: "Загрузка области" };
|
export const Initial: Story = { name: "Загрузка области" };
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
type LoadingSkeletonProps = PropsWithChildren<{
|
type LoadingSkeletonProps = PropsWithChildren<{
|
||||||
label: string;
|
label: string;
|
||||||
+23
-29
@@ -1,36 +1,36 @@
|
|||||||
|
import { Button } from "@mantine/core";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
import { fn } from "storybook/test";
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
import { StateView } from "./StateView";
|
import { StateView } from "./StateView";
|
||||||
|
|
||||||
type StateStoryArguments = ComponentProps<typeof StateView> & {
|
type StateStoryArgs = ComponentProps<typeof StateView> & {
|
||||||
onAction: () => void;
|
|
||||||
withAction: boolean;
|
withAction: boolean;
|
||||||
|
onAction: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
title: "Design System/Components/StateView",
|
||||||
|
component: StateView,
|
||||||
|
tags: ["autodocs"],
|
||||||
args: {
|
args: {
|
||||||
error: false,
|
|
||||||
onAction: fn(),
|
|
||||||
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
|
|
||||||
title: "Нет учебных данных",
|
title: "Нет учебных данных",
|
||||||
|
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
|
||||||
|
error: false,
|
||||||
withAction: false,
|
withAction: false,
|
||||||
|
onAction: fn(),
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
action: { table: { disable: true } },
|
|
||||||
error: { control: "boolean" },
|
|
||||||
onAction: { control: false },
|
|
||||||
text: { control: "text" },
|
|
||||||
title: { control: "text" },
|
title: { control: "text" },
|
||||||
|
text: { control: "text" },
|
||||||
|
error: { control: "boolean" },
|
||||||
|
action: { table: { disable: true } },
|
||||||
withAction: {
|
withAction: {
|
||||||
control: "boolean",
|
control: "boolean",
|
||||||
description: "Показать необязательное действие StateView",
|
description: "Показать необязательное действие StateView",
|
||||||
},
|
},
|
||||||
|
onAction: { control: false },
|
||||||
},
|
},
|
||||||
component: StateView,
|
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -39,46 +39,40 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (arguments_) => {
|
render: (args) => {
|
||||||
const { onAction, withAction, ...stateProps } = arguments_;
|
const { withAction, onAction, ...stateProps } = args;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
{...stateProps}
|
{...stateProps}
|
||||||
action={
|
action={
|
||||||
withAction
|
withAction ? (
|
||||||
? (
|
|
||||||
<Button onClick={onAction} variant="default">
|
<Button onClick={onAction} variant="default">
|
||||||
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
|
{args.error ? "Повторить локально" : "Выполнить пример"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
) : undefined
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
} satisfies Meta<StateStoryArgs>;
|
||||||
title: "Design System/Components/StateView",
|
|
||||||
} satisfies Meta<StateStoryArguments>;
|
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Empty: Story = { name: "Пустое состояние" };
|
export const Empty: Story = { name: "Пустое состояние" };
|
||||||
export const EmptyWithAction: Story = {
|
export const EmptyWithAction: Story = {
|
||||||
args: { withAction: true },
|
|
||||||
name: "Пустое состояние с действием",
|
name: "Пустое состояние с действием",
|
||||||
|
args: { withAction: true },
|
||||||
};
|
};
|
||||||
export const Failed: Story = {
|
export const Failed: Story = {
|
||||||
|
name: "Ошибка",
|
||||||
args: {
|
args: {
|
||||||
error: true,
|
error: true,
|
||||||
text: "Демонстрация недоступных данных без вызова API.",
|
|
||||||
title: "Учебная ошибка",
|
title: "Учебная ошибка",
|
||||||
|
text: "Демонстрация недоступных данных без вызова API.",
|
||||||
},
|
},
|
||||||
name: "Ошибка",
|
|
||||||
};
|
};
|
||||||
export const ErrorWithAction: Story = {
|
export const ErrorWithAction: Story = {
|
||||||
args: { ...Failed.args, withAction: true },
|
|
||||||
name: "Ошибка с retry",
|
name: "Ошибка с retry",
|
||||||
|
args: { ...Failed.args, withAction: true },
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||||
import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
|
|
||||||
import { StateView } from "./StateView";
|
import { StateView } from "./StateView";
|
||||||
|
|
||||||
describe("StateView", () => {
|
describe("StateView", () => {
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
|
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
|
|
||||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
type StateViewProps = {
|
type StateViewProps = {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
text: string;
|
|
||||||
title: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const StateView: FC<StateViewProps> = (props) => {
|
const StateView: FC<StateViewProps> = (props) => {
|
||||||
const { action, error = false, text, title } = props;
|
const { title, text, action, error = false } = props;
|
||||||
const role = error ? "alert" : undefined;
|
const role = error ? "alert" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder>
|
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
|
||||||
<Stack align="flex-start" gap="sm">
|
<Stack align="flex-start" gap="sm">
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
|
|
||||||
<Text c="dimmed">{text}</Text>
|
<Text c="dimmed">{text}</Text>
|
||||||
|
|
||||||
{action}
|
{action}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { EntryCard } from "./EntryCard/EntryCard";
|
export { EntryCard } from "./EntryCard";
|
||||||
export { LoadingSkeleton } from "./LoadingSkeleton/LoadingSkeleton";
|
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||||
export { StateView } from "./StateView/StateView";
|
export { StateView } from "./StateView";
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
|
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
|
||||||
import source from "@wealthledger/tokens/tokens.json";
|
import source from "@wealthledger/tokens/tokens.json";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { colorRoles, cssVariablesResolver, theme } from "./theme";
|
import { colorRoles, cssVariablesResolver, theme } from "./theme";
|
||||||
|
|
||||||
describe("design theme adapter", () => {
|
describe("design theme adapter", () => {
|
||||||
@@ -26,8 +25,5 @@ describe("design theme adapter", () => {
|
|||||||
expect(Object.keys(variables)).toHaveLength(
|
expect(Object.keys(variables)).toHaveLength(
|
||||||
Object.keys(source.color.roles).length,
|
Object.keys(source.color.roles).length,
|
||||||
);
|
);
|
||||||
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
|
|
||||||
variables["--wl-color-muted"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+27
-33
@@ -1,7 +1,8 @@
|
|||||||
|
import "./styles.css";
|
||||||
import {
|
import {
|
||||||
|
type CSSVariablesResolver,
|
||||||
colorsTuple,
|
colorsTuple,
|
||||||
createTheme,
|
createTheme,
|
||||||
type CSSVariablesResolver,
|
|
||||||
getThemeColor,
|
getThemeColor,
|
||||||
type MantinePrimaryShade,
|
type MantinePrimaryShade,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -10,83 +11,76 @@ 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 { color, typography, spacing, radius, shadows, breakpoints } =
|
||||||
|
designTokens;
|
||||||
const { breakpoints, color, radius, shadows, spacing, typography }
|
|
||||||
= designTokens;
|
|
||||||
|
|
||||||
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(values).map(([name, value]) => [name, convert(value)]),
|
Object.entries(values).map(([name, value]) => [name, convert(value)]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const colorRoles = mapValues(color.roles, role =>
|
const colorRoles = mapValues(color.roles, (role) =>
|
||||||
role.shade === null ? role.color : `${role.color}.${role.shade}`,
|
role.shade === null ? role.color : `${role.color}.${role.shade}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
black: color.base.black,
|
respectReducedMotion: true,
|
||||||
breakpoints: mapValues(
|
|
||||||
breakpoints.values,
|
|
||||||
value => `${value}${breakpoints.unit}`,
|
|
||||||
),
|
|
||||||
colors: mapValues(color.palettes, colorsTuple),
|
|
||||||
components: {
|
components: {
|
||||||
Modal: Modal.extend({
|
Modal: Modal.extend({
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
overlayProps: { blur: 3 },
|
overlayProps: { blur: 3 },
|
||||||
transitionProps: {
|
transitionProps: {
|
||||||
|
transition: "fade-down",
|
||||||
duration: 220,
|
duration: 220,
|
||||||
exitDuration: 180,
|
exitDuration: 180,
|
||||||
timingFunction: "ease-out",
|
timingFunction: "ease-out",
|
||||||
transition: "fade-down",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
primaryColor: color.roles.primary.color,
|
||||||
|
primaryShade: color.primaryShade as MantinePrimaryShade,
|
||||||
defaultRadius: radius.default,
|
defaultRadius: radius.default,
|
||||||
|
white: color.base.white,
|
||||||
|
black: color.base.black,
|
||||||
|
colors: mapValues(color.palettes, colorsTuple),
|
||||||
fontFamily: typography.fontFamily,
|
fontFamily: typography.fontFamily,
|
||||||
fontFamilyMonospace: typography.fontFamilyMonospace,
|
fontFamilyMonospace: typography.fontFamilyMonospace,
|
||||||
fontSizes: mapValues(typography.fontSizes, value => rem(value)),
|
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
||||||
fontWeights: mapValues(typography.fontWeights, String),
|
fontWeights: mapValues(typography.fontWeights, String),
|
||||||
|
lineHeights: mapValues(typography.lineHeights, String),
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: typography.fontFamily,
|
fontFamily: typography.fontFamily,
|
||||||
fontWeight: String(typography.fontWeights.bold),
|
fontWeight: String(typography.fontWeights.bold),
|
||||||
sizes: mapValues(typography.headings, value => ({
|
sizes: mapValues(typography.headings, (value) => ({
|
||||||
fontSize: rem(value.fontSize),
|
fontSize: rem(value.fontSize),
|
||||||
lineHeight: String(value.lineHeight),
|
lineHeight: String(value.lineHeight),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
lineHeights: mapValues(typography.lineHeights, String),
|
spacing: mapValues(spacing.values, (value) => rem(value)),
|
||||||
primaryColor: color.roles.primary.color,
|
radius: mapValues(radius.values, (value) => rem(value)),
|
||||||
primaryShade: color.primaryShade as MantinePrimaryShade,
|
shadows: mapValues(shadows.values, (layers) =>
|
||||||
radius: mapValues(radius.values, value => rem(value)),
|
|
||||||
respectReducedMotion: true,
|
|
||||||
shadows: mapValues(shadows.values, layers =>
|
|
||||||
layers
|
layers
|
||||||
.map(
|
.map(
|
||||||
layer =>
|
(layer) =>
|
||||||
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
|
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
|
||||||
)
|
)
|
||||||
.join(", "),
|
.join(", "),
|
||||||
),
|
),
|
||||||
spacing: mapValues(spacing.values, value => rem(value)),
|
breakpoints: mapValues(
|
||||||
white: color.base.white,
|
breakpoints.values,
|
||||||
|
(value) => `${value}${breakpoints.unit}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({
|
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
|
||||||
dark: {},
|
|
||||||
light: {
|
|
||||||
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
|
|
||||||
},
|
|
||||||
variables: Object.fromEntries(
|
variables: Object.fromEntries(
|
||||||
Object.entries(colorRoles).map(([role, reference]) => [
|
Object.entries(colorRoles).map(([role, reference]) => [
|
||||||
`--wl-color-${role}`,
|
`--wl-color-${role}`,
|
||||||
getThemeColor(reference, resolvedTheme),
|
getThemeColor(reference, resolvedTheme),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
light: {},
|
||||||
|
dark: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { colorRoles, cssVariablesResolver, theme };
|
export { colorRoles, cssVariablesResolver, designTokens, theme };
|
||||||
|
|
||||||
export { default as designTokens } from "@wealthledger/tokens/tokens.json";
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { defineConfig } from "vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
cssFileName: "styles",
|
|
||||||
entry: { index: "src/index.ts", theme: "src/theme.ts" },
|
entry: { index: "src/index.ts", theme: "src/theme.ts" },
|
||||||
formats: ["es"],
|
formats: ["es"],
|
||||||
|
cssFileName: "styles",
|
||||||
},
|
},
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
|
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
|
||||||
|
|||||||
@@ -1,37 +1,31 @@
|
|||||||
|
const { test } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const {
|
const {
|
||||||
chmodSync,
|
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
chmodSync,
|
||||||
|
rmSync,
|
||||||
} = require("node:fs");
|
} = require("node:fs");
|
||||||
const { tmpdir } = require("node:os");
|
const { tmpdir } = require("node:os");
|
||||||
const nodePath = require("node:path");
|
const { join, resolve } = require("node:path");
|
||||||
const { test } = require("node:test");
|
|
||||||
const EXECUTABLE_MODE = 0o755;
|
const EXECUTABLE_MODE = 0o755;
|
||||||
const { spawnSync } = require("node:child_process");
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
test("immutable publication resumes exact digest checks after post-push failure", () => {
|
test("immutable publication resumes exact digest checks after post-push failure", () => {
|
||||||
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
|
const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-"));
|
||||||
|
|
||||||
const workflow = readFileSync(
|
const workflow = readFileSync(
|
||||||
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
|
resolve(__dirname, "../.gitea/workflows/image.yml"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const block = workflow
|
const block = workflow
|
||||||
.split(
|
.split(" - name: Publish immutable release tag\n run: |\n")[1]
|
||||||
" - name: Publish immutable release tag\n run: |\n",
|
.split(" - name:")[0]
|
||||||
2,
|
.replace(/^ {10}/gm, "");
|
||||||
)[1]
|
const double = `#!${process.execPath}
|
||||||
.split(" - name:", 1)[0]
|
|
||||||
.replaceAll(/^ {10}/gm, "");
|
|
||||||
|
|
||||||
const double = String.raw`#!${process.execPath}
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const args = process.argv.slice(2).join(' ');
|
const args = process.argv.slice(2).join(' ');
|
||||||
fs.appendFileSync('calls', args + '\n');
|
fs.appendFileSync('calls', args + '\\n');
|
||||||
if (args.startsWith('manifest inspect')) {
|
if (args.startsWith('manifest inspect')) {
|
||||||
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
|
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
|
||||||
} else if (args.startsWith('buildx imagetools inspect')) {
|
} else if (args.startsWith('buildx imagetools inspect')) {
|
||||||
@@ -45,44 +39,37 @@ 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.
|
writeFileSync(join(directory, name), double);
|
||||||
writeFileSync(nodePath.join(directory, name), double);
|
chmodSync(join(directory, name), EXECUTABLE_MODE);
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = (extra = {}) =>
|
const run = (extra = {}) =>
|
||||||
spawnSync("/bin/sh", ["-ec", block], {
|
spawnSync("/bin/sh", ["-ec", block], {
|
||||||
cwd: directory,
|
cwd: directory,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
IMAGE: "synthetic/image",
|
|
||||||
PATH: `${directory}:${process.env.PATH}`,
|
PATH: `${directory}:${process.env.PATH}`,
|
||||||
|
IMAGE: "synthetic/image",
|
||||||
RELEASE_TAG: "v1.2.3",
|
RELEASE_TAG: "v1.2.3",
|
||||||
REVISION: "commit",
|
REVISION: "commit",
|
||||||
...extra,
|
...extra,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.notEqual(run().status, 0);
|
assert.notEqual(run().status, 0);
|
||||||
const retry = run();
|
const retry = run();
|
||||||
assert.equal(retry.status, 0, retry.stdout + retry.stderr);
|
assert.equal(retry.status, 0, retry.stdout + retry.stderr);
|
||||||
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
|
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
|
||||||
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
|
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
const calls = readFileSync(join(directory, "calls"), "utf8");
|
||||||
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls.split("\n").filter(line => line.startsWith("buildx build"))
|
calls.split("\n").filter((line) => line.startsWith("buildx build"))
|
||||||
.length,
|
.length,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert.ok(calls.includes("synthetic/image@sha256:exact"));
|
assert.ok(calls.includes("synthetic/image@sha256:exact"));
|
||||||
}
|
} finally {
|
||||||
finally {
|
rmSync(directory, { recursive: true, force: true });
|
||||||
rmSync(directory, { force: true, recursive: true });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-16
@@ -1,15 +1,14 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import nodePath from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
const temporaryDirectory = await mkdtemp(
|
const temporaryDirectory = await mkdtemp(
|
||||||
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
|
join(tmpdir(), "wealth-ledger-openapi-"),
|
||||||
);
|
);
|
||||||
|
const generatedPath = join(temporaryDirectory, "schema.d.ts");
|
||||||
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
|
const committedPath = resolve("src/shared/api/generated/schema.d.ts");
|
||||||
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
|
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js");
|
||||||
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
|
|
||||||
let exitCode = 0;
|
let exitCode = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -19,13 +18,14 @@ try {
|
|||||||
{ encoding: "utf8" },
|
{ encoding: "utf8" },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (generated.status === 0) {
|
if (generated.status !== 0) {
|
||||||
|
process.stderr.write(generated.stderr || generated.stdout);
|
||||||
|
exitCode = generated.status ?? 1;
|
||||||
|
} else {
|
||||||
const [committed, current] = await Promise.all([
|
const [committed, current] = await Promise.all([
|
||||||
readFile(committedPath, "utf8"),
|
readFile(committedPath, "utf8"),
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
readFile(generatedPath, "utf8"),
|
readFile(generatedPath, "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (committed !== current) {
|
if (committed !== current) {
|
||||||
console.error(
|
console.error(
|
||||||
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
|
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
|
||||||
@@ -33,13 +33,8 @@ try {
|
|||||||
exitCode = 1;
|
exitCode = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
} finally {
|
||||||
process.stderr.write(generated.stderr || generated.stdout);
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
exitCode = generated.status ?? 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exitCode = exitCode;
|
process.exitCode = exitCode;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
const { version } = require("../package.json");
|
const { version } = require("../package.json");
|
||||||
|
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||||
|
|
||||||
const git = (...arguments_) =>
|
assert.equal(
|
||||||
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.
|
git("status", "--porcelain"),
|
||||||
|
"",
|
||||||
const tag_ = git(
|
"Commit all changes before preparing a release",
|
||||||
|
);
|
||||||
|
const tags = git(
|
||||||
"tag",
|
"tag",
|
||||||
"--merged",
|
"--merged",
|
||||||
"HEAD",
|
"HEAD",
|
||||||
@@ -15,10 +17,9 @@ const tag_ = git(
|
|||||||
"--sort=-version:refname",
|
"--sort=-version:refname",
|
||||||
)
|
)
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
version,
|
version,
|
||||||
tag_?.slice(1) ?? "0.0.0",
|
tags[0]?.slice(1) ?? "0.0.0",
|
||||||
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
|
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,395 +0,0 @@
|
|||||||
import react from "@eslint-react/eslint-plugin";
|
|
||||||
import stylistic from "@stylistic/eslint-plugin";
|
|
||||||
import query from "@tanstack/eslint-plugin-query";
|
|
||||||
import vitest from "@vitest/eslint-plugin";
|
|
||||||
import { ESLint } from "eslint";
|
|
||||||
import { importX } from "eslint-plugin-import-x";
|
|
||||||
import jestDom from "eslint-plugin-jest-dom";
|
|
||||||
import noUnsanitized from "eslint-plugin-no-unsanitized";
|
|
||||||
import perfectionist from "eslint-plugin-perfectionist";
|
|
||||||
import hooks from "eslint-plugin-react-hooks";
|
|
||||||
import refresh from "eslint-plugin-react-refresh";
|
|
||||||
import regexp from "eslint-plugin-regexp";
|
|
||||||
import security from "eslint-plugin-security";
|
|
||||||
import sonarjs from "eslint-plugin-sonarjs";
|
|
||||||
import storybook from "eslint-plugin-storybook";
|
|
||||||
import testingLibrary from "eslint-plugin-testing-library";
|
|
||||||
import unicorn from "eslint-plugin-unicorn";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { globSync } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
|
||||||
const eslint = new ESLint();
|
|
||||||
|
|
||||||
for (const [filePath, code, rule] of [
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export function 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",
|
|
||||||
"export function run() { Promise.resolve(); }",
|
|
||||||
"@typescript-eslint/no-floating-promises",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"packages/ui/src/EntryCard/EntryCard.tsx",
|
|
||||||
"import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
|
|
||||||
"react-hooks/rules-of-hooks",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export const values = Array(3).fill({});",
|
|
||||||
"unicorn/no-array-fill-with-reference-type",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export default 1;",
|
|
||||||
"import-x/no-default-export",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"new Error(\"forgot to throw\");",
|
|
||||||
"sonarjs/no-unthrown-error",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export const pattern = /[aab]/;",
|
|
||||||
"regexp/no-dupe-characters-character-class",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export type Status = \"z\" | \"a\";",
|
|
||||||
"perfectionist/sort-union-types",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"//missing space\nexport const ready = true;",
|
|
||||||
"@stylistic/spaced-comment",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export function pattern(input: string) { return new RegExp(input); }",
|
|
||||||
"security/detect-non-literal-regexp",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"scripts/check-openapi.mjs",
|
|
||||||
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
|
|
||||||
"security/detect-child-process",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export function renderHtml(element: HTMLElement, html: string) { element.innerHTML = html; }",
|
|
||||||
"no-unsanitized/property",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/problem-details.ts",
|
|
||||||
"export function renderHtml(element: HTMLElement, html: string) { element.insertAdjacentHTML(\"beforeend\", html); }",
|
|
||||||
"no-unsanitized/method",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"packages/ui/src/EntryCard/EntryCard.test.tsx",
|
|
||||||
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
|
|
||||||
"jest-dom/prefer-focus",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"src/shared/api/client.test.ts",
|
|
||||||
"import { it } from \"vitest\"; it.only(\"focused\", () => {});",
|
|
||||||
"vitest/no-focused-tests",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"packages/ui/src/EntryCard/EntryCard.test.tsx",
|
|
||||||
"import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
|
|
||||||
"testing-library/await-async-queries",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"packages/ui/src/EntryCard/EntryCard.tsx",
|
|
||||||
"import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
|
|
||||||
"@tanstack/query/stable-query-client",
|
|
||||||
],
|
|
||||||
]) {
|
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
|
||||||
assert.equal(result.fatalErrorCount, 0, filePath);
|
|
||||||
assert.ok(
|
|
||||||
result.messages.some(message => message.ruleId === rule),
|
|
||||||
rule,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of [
|
|
||||||
"src/shared/api/generated/schema.d.ts",
|
|
||||||
"packages/ui/dist/index.js",
|
|
||||||
"storybook-static/index.js",
|
|
||||||
"coverage/index.js",
|
|
||||||
])
|
|
||||||
assert.equal(await eslint.isPathIgnored(file), true, file);
|
|
||||||
|
|
||||||
const story = await eslint.calculateConfigForFile(
|
|
||||||
"src/shared/ui/Button.stories.tsx",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
|
||||||
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
|
||||||
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recommended plugin rules retain the explicit null-contract exception", async () => {
|
|
||||||
const eslint = new ESLint();
|
|
||||||
const source = "src/shared/api/problem-details.ts";
|
|
||||||
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
|
||||||
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
|
||||||
|
|
||||||
for (const [preset, filePath] of [
|
|
||||||
[tseslint.configs.recommendedTypeChecked, source],
|
|
||||||
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
|
||||||
[unicorn.configs.recommended, source],
|
|
||||||
[sonarjs.configs.recommended, source],
|
|
||||||
[regexp.configs.recommended, source],
|
|
||||||
[security.configs.recommended, source],
|
|
||||||
[noUnsanitized.configs.recommended, source],
|
|
||||||
[perfectionist.configs["recommended-natural"], source],
|
|
||||||
[stylistic.configs.recommended, component],
|
|
||||||
[react.configs["recommended-type-checked"], component],
|
|
||||||
[hooks.configs.flat.recommended, component],
|
|
||||||
[refresh.configs.vite, component],
|
|
||||||
[query.configs["flat/recommended"], component],
|
|
||||||
[vitest.configs.recommended, unit],
|
|
||||||
[testingLibrary.configs["flat/react"], unit],
|
|
||||||
[jestDom.configs["flat/recommended"], unit],
|
|
||||||
...storybook.configs["flat/recommended"]
|
|
||||||
.filter(config => config.rules)
|
|
||||||
.map(config => [
|
|
||||||
config,
|
|
||||||
config.name.endsWith("main-rules")
|
|
||||||
? ".storybook/main.ts"
|
|
||||||
: "src/shared/ui/Button.stories.tsx",
|
|
||||||
]),
|
|
||||||
]) {
|
|
||||||
const configs = [preset].flat();
|
|
||||||
|
|
||||||
const expected = Object.assign(
|
|
||||||
{},
|
|
||||||
...configs.map(config => config.rules),
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = await eslint.calculateConfigForFile(filePath);
|
|
||||||
const actual = new Map(Object.entries(config.rules));
|
|
||||||
|
|
||||||
for (const [rule, setting] of Object.entries(expected)) {
|
|
||||||
const severity = Array.isArray(setting) ? setting[0] : setting;
|
|
||||||
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
|
||||||
|
|
||||||
if (rule === "unicorn/no-null") {
|
|
||||||
assert.equal(actual.get(rule)?.[0], 0);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
|
||||||
const eslint = new ESLint({ fix: true });
|
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
|
||||||
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
|
||||||
{ filePath },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(fixed.fatalErrorCount, 0);
|
|
||||||
assert.equal(fixed.errorCount, 0);
|
|
||||||
assert.ok(fixed.output);
|
|
||||||
const [again] = await eslint.lintText(fixed.output, { filePath });
|
|
||||||
assert.equal(again.errorCount, 0);
|
|
||||||
assert.equal(again.output, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
|
||||||
const eslint = new ESLint({ fix: true });
|
|
||||||
|
|
||||||
const [fixed] = await eslint.lintText(
|
|
||||||
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
|
||||||
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(fixed.errorCount, 0);
|
|
||||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
|
|
||||||
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
|
||||||
const eslint = new ESLint();
|
|
||||||
const filePath = "src/shared/api/problem-details.ts";
|
|
||||||
|
|
||||||
for (const code of [
|
|
||||||
"export function check(code: string) { return code === \"one-off-code\"; }",
|
|
||||||
"export const result = `MOEX_UNAVAILABLE`;",
|
|
||||||
"export const result = { code: \"one-off-code\" };",
|
|
||||||
"export const result = { \"errorCode\": \"one-off-code\" };",
|
|
||||||
"export const result = { code: `dynamic-${Date.now()}` };",
|
|
||||||
"export const result = \"MOEX_UNAVAILABLE\";",
|
|
||||||
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
|
|
||||||
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
|
|
||||||
]) {
|
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
|
||||||
assert.ok(
|
|
||||||
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
|
|
||||||
code,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const code of [
|
|
||||||
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
|
||||||
"export const select = { code: true, errorCode: true };",
|
|
||||||
"export const label = \"Please try again\"; export const key = \"JWT_SECRET\";",
|
|
||||||
]) {
|
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
|
||||||
assert.ok(
|
|
||||||
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
|
||||||
code,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fixture] = await eslint.lintText(
|
|
||||||
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
|
|
||||||
{ filePath: "src/shared/api/client.test.ts" },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [comparison] = await eslint.lintText(
|
|
||||||
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
|
|
||||||
{ filePath },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
comparison.messages.some(
|
|
||||||
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readability enforces syntax without banning VM and schema patterns", async () => {
|
|
||||||
const eslint = new ESLint();
|
|
||||||
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
|
|
||||||
const model = "src/entities/income/api/useIncome.ts";
|
|
||||||
|
|
||||||
const invalid = [
|
|
||||||
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
|
|
||||||
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
|
|
||||||
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
|
|
||||||
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
|
|
||||||
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
|
|
||||||
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
|
|
||||||
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
|
|
||||||
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
|
|
||||||
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
|
|
||||||
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [filePath, code, rule] of invalid) {
|
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
|
||||||
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [filePath, code] of [
|
|
||||||
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
|
|
||||||
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
|
|
||||||
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
|
|
||||||
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
|
|
||||||
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
|
|
||||||
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
|
|
||||||
]) {
|
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
|
||||||
assert.equal(result.fatalErrorCount, 0, code);
|
|
||||||
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.deepEqual(globSync("src/**/*.svg"), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
|
|
||||||
const eslint = new ESLint({ fix: true });
|
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
|
||||||
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
|
|
||||||
const output = fixed.output;
|
|
||||||
assert.ok(output);
|
|
||||||
const [again] = await eslint.lintText(output, { filePath });
|
|
||||||
assert.equal(again.output, undefined);
|
|
||||||
assert.equal(again.errorCount, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("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
-32
@@ -9,71 +9,53 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import nodePath from "node:path";
|
import { delimiter, join, resolve } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
const GITHUB_TOKEN_RANDOM_BYTES = 18;
|
const GITHUB_TOKEN_RANDOM_BYTES = 18;
|
||||||
|
|
||||||
test("pre-commit scans the index, redacts findings and fails closed", () => {
|
test("pre-commit scans the index, redacts findings and fails closed", () => {
|
||||||
const hook = nodePath.resolve(".husky/pre-commit");
|
const hook = resolve(".husky/pre-commit");
|
||||||
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
|
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-"));
|
||||||
const bin = nodePath.join(cwd, "bin");
|
const bin = join(cwd, "bin");
|
||||||
const marker = nodePath.join(cwd, "downstream");
|
const marker = join(cwd, "downstream");
|
||||||
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
|
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
|
||||||
|
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" });
|
||||||
const git = (...arguments_) =>
|
const run = (path = `${bin}${delimiter}${process.env.PATH}`) =>
|
||||||
execFileSync("git", arguments_, { cwd, stdio: "pipe" }); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
|
||||||
|
|
||||||
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
|
|
||||||
spawnSync("/bin/sh", ["-e", hook], {
|
spawnSync("/bin/sh", ["-e", hook], {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: "utf8",
|
|
||||||
env: { ...process.env, PATH: path },
|
env: { ...process.env, PATH: path },
|
||||||
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
mkdirSync(bin);
|
mkdirSync(bin);
|
||||||
|
|
||||||
for (const command of ["npx", "npm"]) {
|
for (const command of ["npx", "npm"]) {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
|
||||||
writeFileSync(
|
|
||||||
nodePath.join(bin, command),
|
|
||||||
"#!/bin/sh\necho ran >> downstream\n",
|
|
||||||
{
|
|
||||||
mode: 0o755,
|
mode: 0o755,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
git("init", "--quiet");
|
git("init", "--quiet");
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n");
|
||||||
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
|
||||||
git("add", "sample.txt");
|
git("add", "sample.txt");
|
||||||
// An unstaged secret must not be mistaken for the index being committed.
|
// An unstaged secret must not be mistaken for the index being committed.
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`);
|
||||||
writeFileSync(nodePath.join(cwd, "sample.txt"), `token=${token}\n`);
|
|
||||||
let result = run();
|
let result = run();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.status,
|
result.status,
|
||||||
0,
|
0,
|
||||||
"clean index must pass; install Gitleaks first",
|
"clean index must pass; install Gitleaks first",
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
assert.ok(existsSync(marker));
|
assert.ok(existsSync(marker));
|
||||||
rmSync(marker);
|
rmSync(marker);
|
||||||
git("add", "sample.txt");
|
git("add", "sample.txt");
|
||||||
result = run();
|
result = run();
|
||||||
assert.equal(result.status, 1, "staged secret must block commit");
|
assert.equal(result.status, 1, "staged secret must block commit");
|
||||||
assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
|
assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
|
assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
|
||||||
result = run(bin);
|
result = run(bin);
|
||||||
assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
|
assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
assert.ok(!existsSync(marker));
|
assert.ok(!existsSync(marker));
|
||||||
}
|
} finally {
|
||||||
finally {
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
rmSync(cwd, { force: true, recursive: true });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { useCurrentRoute } from "./useCurrentRoute";
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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 };
|
|
||||||
+75
-162
@@ -1,5 +1,3 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -9,119 +7,104 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
|
import type { FC } from "react";
|
||||||
import { MemoryRouter, useLocation } from "react-router";
|
import { MemoryRouter, useLocation } from "react-router";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
import type { IncomeAnalytics } from "@/entities/income";
|
import type { IncomeAnalytics } from "@/entities/income";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
const accounts = [
|
const accounts = [
|
||||||
{ id: "a", name: "Основной" },
|
{ id: "a", name: "Основной" },
|
||||||
{ id: "b", name: "ИИС" },
|
{ id: "b", name: "ИИС" },
|
||||||
];
|
];
|
||||||
|
const position: Portfolio["positions"][number] = {
|
||||||
const position: Portfolio["positions"][number]
|
figi: "SBER",
|
||||||
= {
|
displayName: "Сбербанк",
|
||||||
|
type: "STOCK",
|
||||||
|
quantity: "2",
|
||||||
|
currentValue: { currency: "RUB", value: "600" },
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
currentValue: { currency: "RUB", value: "600" },
|
|
||||||
displayName: "Сбербанк",
|
|
||||||
figi: "SBER",
|
|
||||||
isin: null,
|
isin: null,
|
||||||
logoName: null,
|
|
||||||
quantity: "2",
|
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
type: "STOCK",
|
logoName: null,
|
||||||
};
|
|
||||||
|
|
||||||
const portfolio: Portfolio = {
|
|
||||||
cashBalances: [],
|
|
||||||
isStale: false,
|
|
||||||
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
|
||||||
positions: accounts.map(account => ({ ...position, account })),
|
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
|
||||||
};
|
};
|
||||||
|
const portfolio: Portfolio = {
|
||||||
const receipt: BrokerageOperation
|
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
||||||
= {
|
isStale: false,
|
||||||
accruedInterest: null,
|
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
|
cashBalances: [],
|
||||||
|
positions: accounts.map((account) => ({ ...position, account })),
|
||||||
|
};
|
||||||
|
const receipt: BrokerageOperation = {
|
||||||
|
id: "same",
|
||||||
|
occurredAt: "2026-08-15T12:00:00Z",
|
||||||
|
type: "OPERATION_TYPE_COUPON",
|
||||||
category: "income",
|
category: "income",
|
||||||
commission: null,
|
|
||||||
description: "Выплата купона",
|
description: "Выплата купона",
|
||||||
figi: null,
|
figi: null,
|
||||||
id: "same",
|
|
||||||
instrumentType: "bond",
|
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
instrumentType: "bond",
|
||||||
logoName: null,
|
logoName: null,
|
||||||
occurredAt: "2026-08-15T12:00:00Z",
|
|
||||||
payment: { currency: "RUB", value: "1" },
|
payment: { currency: "RUB", value: "1" },
|
||||||
price: null,
|
|
||||||
quantity: null,
|
quantity: null,
|
||||||
ticker: "BOND",
|
price: null,
|
||||||
type: "OPERATION_TYPE_COUPON",
|
accruedInterest: null,
|
||||||
};
|
commission: null,
|
||||||
|
};
|
||||||
const items = accounts.map(account => ({ ...receipt, account }));
|
const items = accounts.map((account) => ({ ...receipt, account }));
|
||||||
|
const analytics: IncomeAnalytics = {
|
||||||
const analytics: IncomeAnalytics
|
|
||||||
= {
|
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
bucketGranularity: "day",
|
period: { from: "2026-08-01", to: "2026-08-31" },
|
||||||
buckets: [],
|
|
||||||
calculatedAt: "2026-09-01T12:00:00Z",
|
calculatedAt: "2026-09-01T12:00:00Z",
|
||||||
entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })),
|
totalsByCurrency: [
|
||||||
instruments: [
|
|
||||||
{
|
{
|
||||||
|
currency: "RUB",
|
||||||
coupon: { currency: "RUB", value: "2" },
|
coupon: { currency: "RUB", value: "2" },
|
||||||
displayName: "Облигация",
|
|
||||||
dividend: { currency: "RUB", value: "0" },
|
dividend: { currency: "RUB", value: "0" },
|
||||||
figi: null,
|
|
||||||
instrumentType: "bond",
|
|
||||||
instrumentUid: null,
|
|
||||||
key: "bond:RUB",
|
|
||||||
receiptCount: 2,
|
|
||||||
ticker: "BOND",
|
|
||||||
total: { currency: "RUB", value: "2" },
|
total: { currency: "RUB", value: "2" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
period: { from: "2026-08-01", to: "2026-08-31" },
|
|
||||||
previous: {
|
previous: {
|
||||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
},
|
},
|
||||||
totalsByCurrency: [
|
yearComparison: null,
|
||||||
|
bucketGranularity: "day",
|
||||||
|
buckets: [],
|
||||||
|
instruments: [
|
||||||
{
|
{
|
||||||
|
key: "bond:RUB",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
instrumentType: "bond",
|
||||||
|
displayName: "Облигация",
|
||||||
coupon: { currency: "RUB", value: "2" },
|
coupon: { currency: "RUB", value: "2" },
|
||||||
currency: "RUB",
|
|
||||||
dividend: { currency: "RUB", value: "0" },
|
dividend: { currency: "RUB", value: "0" },
|
||||||
total: { currency: "RUB", value: "2" },
|
total: { currency: "RUB", value: "2" },
|
||||||
|
receiptCount: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
yearComparison: null,
|
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentLocation: FC = () => {
|
const CurrentLocation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<output aria-label="Текущий путь">
|
<output aria-label="Текущий путь">
|
||||||
{location.pathname}
|
{location.pathname}
|
||||||
|
|
||||||
{location.search}
|
{location.search}
|
||||||
</output>
|
</output>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPage(path: string) {
|
function renderPage(path: string) {
|
||||||
const client = createTestQueryClient();
|
const client = createTestQueryClient();
|
||||||
render(
|
render(
|
||||||
@@ -130,14 +113,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,73 +127,56 @@ beforeEach(() => {
|
|||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
accessToken: "test",
|
accessToken: "test",
|
||||||
user: { email: "user@example.com", id: "user" },
|
user: { id: "user", email: "user@example.com" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource(
|
accounts.map((account) => ({
|
||||||
accounts.map(
|
|
||||||
|
|
||||||
account =>
|
|
||||||
({
|
|
||||||
...account,
|
...account,
|
||||||
|
type: "BROKERAGE",
|
||||||
openedOn: null,
|
openedOn: null,
|
||||||
portfolioSummary: null,
|
portfolioSummary: null,
|
||||||
type: "BROKERAGE",
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(resource(portfolio)),
|
HttpResponse.json(portfolio),
|
||||||
),
|
),
|
||||||
http.get(
|
http.get(
|
||||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
({ params }) =>
|
({ params }) =>
|
||||||
HttpResponse.json(
|
HttpResponse.json({
|
||||||
|
|
||||||
resource(
|
|
||||||
{
|
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: false,
|
|
||||||
items,
|
items,
|
||||||
|
hasNext: false,
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||||
HttpResponse.json(resource(analytics)),
|
HttpResponse.json(analytics),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json({
|
||||||
resource({
|
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
calculatedAt: receipt.occurredAt,
|
calculatedAt: receipt.occurredAt,
|
||||||
events: accounts.map(
|
incomeTotalsByCurrency: [],
|
||||||
|
events: accounts.map((account) => ({
|
||||||
account =>
|
|
||||||
({
|
|
||||||
account,
|
account,
|
||||||
|
type: "coupon",
|
||||||
date: "2026-09-15",
|
date: "2026-09-15",
|
||||||
dateKind: "event",
|
dateKind: "event",
|
||||||
displayName: "Облигация",
|
|
||||||
figi: null,
|
figi: null,
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
displayName: "Облигация",
|
||||||
isin: null,
|
isin: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
projectedAmount: { currency: "RUB", value: "2" },
|
projectedAmount: { currency: "RUB", value: "2" },
|
||||||
ticker: "BOND",
|
})),
|
||||||
type: "coupon",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
incomeTotalsByCurrency: [],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,34 +202,6 @@ 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);
|
||||||
@@ -276,11 +212,7 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
for (const suffix of ["operations", "income", "events"])
|
for (const suffix of ["operations", "income", "events"])
|
||||||
expect(
|
expect(
|
||||||
screen
|
document.querySelector(`a[href="/accounts/all/${suffix}"]`),
|
||||||
.getAllByRole("link")
|
|
||||||
.find(
|
|
||||||
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,16 +221,11 @@ describe("All accounts routes", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
({ params, request }) => {
|
({ request, params }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
|
return HttpResponse.json({
|
||||||
return HttpResponse.json(
|
|
||||||
|
|
||||||
resource(
|
|
||||||
{
|
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
hasNext: !url.searchParams.has("cursor"),
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
...receipt,
|
...receipt,
|
||||||
@@ -307,11 +234,10 @@ describe("All accounts routes", () => {
|
|||||||
: "Первая страница",
|
: "Первая страница",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
hasNext: !url.searchParams.has("cursor"),
|
||||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
},
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -325,14 +251,12 @@ describe("All accounts routes", () => {
|
|||||||
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
requests.some(url => url.pathname.includes("/b/operations")),
|
requests.some((url) => url.pathname.includes("/b/operations")),
|
||||||
).toBe(true),
|
).toBe(true),
|
||||||
);
|
);
|
||||||
|
const selected = requests.find((url) =>
|
||||||
const selected = requests.find(url =>
|
|
||||||
url.pathname.includes("/b/operations"),
|
url.pathname.includes("/b/operations"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
||||||
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
||||||
expect(selected?.searchParams.get("categories")).toBe("income");
|
expect(selected?.searchParams.get("categories")).toBe("income");
|
||||||
@@ -340,11 +264,6 @@ 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 () => {
|
||||||
@@ -355,23 +274,19 @@ describe("All accounts routes", () => {
|
|||||||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
: HttpResponse.json(
|
: HttpResponse.json({
|
||||||
resource({
|
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
hasNext: true,
|
|
||||||
items,
|
items,
|
||||||
|
hasNext: true,
|
||||||
nextCursor: "expired",
|
nextCursor: "expired",
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
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: "Далее" }));
|
||||||
await waitFor(() => {
|
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||||
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",
|
||||||
@@ -379,7 +294,7 @@ describe("All accounts routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("retains the complete portfolio during a failed refresh", async () => {
|
it("retains the complete portfolio during a failed refresh", async () => {
|
||||||
const view = renderPage("/accounts/all");
|
const client = renderPage("/accounts/all");
|
||||||
await screen.findAllByText("Сбербанк");
|
await screen.findAllByText("Сбербанк");
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
|
||||||
@@ -387,17 +302,15 @@ describe("All accounts routes", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
view.invalidateQueries({
|
client.invalidateQueries({
|
||||||
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
|
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", {
|
await screen.findByRole("button", {
|
||||||
name: "Повторить",
|
name: "Повторить",
|
||||||
}),
|
}),
|
||||||
).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
|
describe("AppRoutes", () => {
|
||||||
|
function renderRoutes(path: string) {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<MantineProvider
|
||||||
|
cssVariablesResolver={cssVariablesResolver}
|
||||||
|
theme={theme}
|
||||||
|
>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/", "Вход"],
|
||||||
|
["/login", "Вход"],
|
||||||
|
["/register", "Регистрация"],
|
||||||
|
["/accounts", "Вход"],
|
||||||
|
["/accounts/demo", "Вход"],
|
||||||
|
["/accounts/demo/operations", "Вход"],
|
||||||
|
["/settings", "Вход"],
|
||||||
|
["/cards?month=2026-08", "Вход"],
|
||||||
|
["/unknown", "Страница не найдена"],
|
||||||
|
])("renders %s with its page title", async (path, title) => {
|
||||||
|
renderRoutes(path);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 1, name: title }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(document.title).toBe(`${title} — WealthLedger`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects anonymous users from accounts", async () => {
|
||||||
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 1, name: "Вход" }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in through the public form and reaches the protected landing", async () => {
|
||||||
|
let credentials: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/auth/login", async ({ request }) => {
|
||||||
|
credentials = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accessToken: "login-token",
|
||||||
|
user: { id: "user-1", email: "user@example.com" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { level: 1, 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: "Вход" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Нет инвестиционных счетов",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(document.title).toBe("Счета — WealthLedger");
|
||||||
|
expect(credentials).toEqual({
|
||||||
|
email: "user@example.com",
|
||||||
|
password: "a secure password",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns after login to cards with the selected month without T-Bank", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
||||||
|
http.post("*/api/v1/auth/login", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accessToken: "test",
|
||||||
|
user: { id: "user-1", email: "user@example.com" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
||||||
|
http.get("*/api/v1/bank-cards/summary", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
month: "2026-08",
|
||||||
|
cardId: null,
|
||||||
|
expenses: { currency: "RUB", value: "0" },
|
||||||
|
refunds: { currency: "RUB", value: "0" },
|
||||||
|
net: { currency: "RUB", value: "0" },
|
||||||
|
categories: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderRoutes("/cards?month=2026-08");
|
||||||
|
await screen.findByRole("heading", { 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: "Вход" }));
|
||||||
|
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.08.2026 – 31.08.2026",
|
||||||
|
);
|
||||||
|
expect(document.title).toBe("Карты — WealthLedger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accessToken: "test-token",
|
||||||
|
user: { id: "user-1", email: "user@example.com" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Нет инвестиционных счетов",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,258 +1,242 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
|
import { LazyPage } from "./RouteFallback";
|
||||||
import { LazyPage } from "../RouteFallback";
|
import { Protected, Public } from "./RouteGuards";
|
||||||
import { Protected, Public } from "../RouteGuards";
|
|
||||||
|
|
||||||
const CardPage = lazy(async () => {
|
const CardPage = lazy(async () => {
|
||||||
const { CardPage } = await import("@/pages/card");
|
const { CardPage } = await import("@/pages/card");
|
||||||
|
|
||||||
return { default: CardPage };
|
return { default: CardPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const CardsPage = lazy(async () => {
|
const CardsPage = lazy(async () => {
|
||||||
const { CardsPage } = await import("@/pages/cards");
|
const { CardsPage } = await import("@/pages/cards");
|
||||||
|
|
||||||
return { default: CardsPage };
|
return { default: CardsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const BondPage = lazy(async () => {
|
const BondPage = lazy(async () => {
|
||||||
const { BondPage } = await import("@/pages/bond");
|
const { BondPage } = await import("@/pages/bond");
|
||||||
|
|
||||||
return { default: BondPage };
|
return { default: BondPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const BondsPage = lazy(async () => {
|
const BondsPage = lazy(async () => {
|
||||||
const { BondsPage } = await import("@/pages/bonds");
|
const { BondsPage } = await import("@/pages/bonds");
|
||||||
|
|
||||||
return { default: BondsPage };
|
return { default: BondsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const StockPage = lazy(async () => {
|
const StockPage = lazy(async () => {
|
||||||
const { StockPage } = await import("@/pages/stock");
|
const { StockPage } = await import("@/pages/stock");
|
||||||
|
|
||||||
return { default: StockPage };
|
return { default: StockPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const StocksPage = lazy(async () => {
|
const StocksPage = lazy(async () => {
|
||||||
const { StocksPage } = await import("@/pages/stocks");
|
const { StocksPage } = await import("@/pages/stocks");
|
||||||
|
|
||||||
return { default: StocksPage };
|
return { default: StocksPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountPage = lazy(async () => {
|
const AccountPage = lazy(async () => {
|
||||||
const { AccountPage } = await import("@/pages/account");
|
const { AccountPage } = await import("@/pages/account");
|
||||||
|
|
||||||
return { default: AccountPage };
|
return { default: AccountPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountEventsPage = lazy(async () => {
|
const AccountEventsPage = lazy(async () => {
|
||||||
const { AccountEventsPage } = await import("@/pages/account-events");
|
const { AccountEventsPage } = await import("@/pages/account-events");
|
||||||
|
|
||||||
return { default: AccountEventsPage };
|
return { default: AccountEventsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountOperationsPage = lazy(async () => {
|
const AccountOperationsPage = lazy(async () => {
|
||||||
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
||||||
|
|
||||||
return { default: AccountOperationsPage };
|
return { default: AccountOperationsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountIncomePage = lazy(async () => {
|
const AccountIncomePage = lazy(async () => {
|
||||||
const { AccountIncomePage } = await import("@/pages/account-income");
|
const { AccountIncomePage } = await import("@/pages/account-income");
|
||||||
|
|
||||||
return { default: AccountIncomePage };
|
return { default: AccountIncomePage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountsPage = lazy(async () => {
|
const AccountsPage = lazy(async () => {
|
||||||
const { AccountsPage } = await import("@/pages/accounts");
|
const { AccountsPage } = await import("@/pages/accounts");
|
||||||
|
|
||||||
return { default: AccountsPage };
|
return { default: AccountsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const LoginPage = lazy(async () => {
|
const LoginPage = lazy(async () => {
|
||||||
const { LoginPage } = await import("@/pages/login");
|
const { LoginPage } = await import("@/pages/login");
|
||||||
|
|
||||||
return { default: LoginPage };
|
return { default: LoginPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const NotFoundPage = lazy(async () => {
|
const NotFoundPage = lazy(async () => {
|
||||||
const { NotFoundPage } = await import("@/pages/not-found");
|
const { NotFoundPage } = await import("@/pages/not-found");
|
||||||
|
|
||||||
return { default: NotFoundPage };
|
return { default: NotFoundPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const RegisterPage = lazy(async () => {
|
const RegisterPage = lazy(async () => {
|
||||||
const { RegisterPage } = await import("@/pages/register");
|
const { RegisterPage } = await import("@/pages/register");
|
||||||
|
|
||||||
return { default: RegisterPage };
|
return { default: RegisterPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsPage = lazy(async () => {
|
const SettingsPage = lazy(async () => {
|
||||||
const { SettingsPage } = await import("@/pages/settings");
|
const { SettingsPage } = await import("@/pages/settings");
|
||||||
|
|
||||||
return { default: SettingsPage };
|
return { default: SettingsPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const ProtectedLayout = lazy(async () => {
|
const ProtectedLayout = lazy(async () => {
|
||||||
const { ProtectedLayout } = await import("../ProtectedLayout");
|
const { ProtectedLayout } = await import("./ProtectedLayout");
|
||||||
|
|
||||||
return { default: ProtectedLayout };
|
return { default: ProtectedLayout };
|
||||||
});
|
});
|
||||||
|
|
||||||
const AppRoutes: FC = () => (
|
const AppRoutes: FC = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Navigate replace to="/accounts" />} path="/" />
|
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/login"
|
||||||
|
element={
|
||||||
<Public>
|
<Public>
|
||||||
<LazyPage variant="auth">
|
<LazyPage variant="auth">
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
</Public>
|
</Public>
|
||||||
)}
|
}
|
||||||
path="/login"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/register"
|
||||||
|
element={
|
||||||
<Public>
|
<Public>
|
||||||
<LazyPage variant="auth">
|
<LazyPage variant="auth">
|
||||||
<RegisterPage />
|
<RegisterPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
</Public>
|
</Public>
|
||||||
)}
|
}
|
||||||
path="/register"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
element={
|
||||||
<Protected>
|
<Protected>
|
||||||
<LazyPage variant="shell">
|
<LazyPage variant="shell">
|
||||||
<ProtectedLayout />
|
<ProtectedLayout />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
</Protected>
|
</Protected>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/cards/:cardId"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<CardPage />
|
<CardPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/cards/:cardId"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/cards"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<CardsPage />
|
<CardsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/cards"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/bonds"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<BondsPage />
|
<BondsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/bonds"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/bonds/:bondId"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<BondPage />
|
<BondPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/bonds/:bondId"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/stocks"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<StocksPage />
|
<StocksPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/stocks"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/stocks/:stockId"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<StockPage />
|
<StockPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/stocks/:stockId"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/accounts"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<AccountsPage />
|
<AccountsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/accounts"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/accounts/all"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/operations"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountOperationsPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/income"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountIncomePage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/events"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountEventsPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<AccountPage />
|
<AccountPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/accounts/:accountId"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/accounts/:accountId/events"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<AccountEventsPage />
|
<AccountEventsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/accounts/:accountId/events"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/accounts/:accountId/income"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<AccountIncomePage />
|
<AccountIncomePage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/accounts/:accountId/income"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/accounts/:accountId/operations"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<AccountOperationsPage />
|
<AccountOperationsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/accounts/:accountId/operations"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="/settings"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="/settings"
|
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
path="*"
|
||||||
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<NotFoundPage />
|
<NotFoundPage />
|
||||||
</LazyPage>
|
</LazyPage>
|
||||||
)}
|
}
|
||||||
path="*"
|
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import { type InitialEntry, Link, MemoryRouter } from "react-router";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
|
||||||
import { createTestQueryClient } from "@/test/query";
|
|
||||||
import { server } from "@/test/server";
|
|
||||||
|
|
||||||
import { AppRoutes } from "./AppRoutes";
|
|
||||||
|
|
||||||
function renderRoutes(path: InitialEntry, controls?: ReactNode) {
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
|
||||||
<MemoryRouter initialEntries={[path]}>
|
|
||||||
{controls}
|
|
||||||
<MantineProvider
|
|
||||||
cssVariablesResolver={cssVariablesResolver}
|
|
||||||
theme={theme}
|
|
||||||
>
|
|
||||||
<AuthProvider>
|
|
||||||
<AppRoutes />
|
|
||||||
</AuthProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AppRoutes", () => {
|
|
||||||
it.each([
|
|
||||||
["/", "Вход"],
|
|
||||||
["/login", "Вход"],
|
|
||||||
["/register", "Регистрация"],
|
|
||||||
["/accounts", "Вход"],
|
|
||||||
["/accounts/demo", "Вход"],
|
|
||||||
["/accounts/demo/operations", "Вход"],
|
|
||||||
["/settings", "Вход"],
|
|
||||||
["/cards?month=2026-08", "Вход"],
|
|
||||||
["/unknown", "Страница не найдена"],
|
|
||||||
])("renders %s with its page title", async (path, title) => {
|
|
||||||
renderRoutes(path);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", { level: 1, name: title }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(document.title).toBe(`${title} — WealthLedger`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects anonymous users from accounts", async () => {
|
|
||||||
renderRoutes("/accounts");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "Вход" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
server.use(
|
|
||||||
http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
|
|
||||||
credentials = await request.json();
|
|
||||||
|
|
||||||
return HttpResponse.json({
|
|
||||||
accessToken: "login-token",
|
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
renderRoutes(path);
|
|
||||||
|
|
||||||
await screen.findByRole("heading", { level: 1, name: title });
|
|
||||||
fireEvent.change(screen.getByLabelText("Email"), {
|
|
||||||
target: { value: " User@Example.com " },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
|
||||||
target: { value: "a secure password" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (endpoint === "register") {
|
|
||||||
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
|
|
||||||
target: { value: "a secure password" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: title }));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
level: 2,
|
|
||||||
name: "Нет инвестиционных счетов",
|
|
||||||
}),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(document.title).toBe("Счета — WealthLedger");
|
|
||||||
expect(credentials).toEqual({
|
|
||||||
email: "user@example.com",
|
|
||||||
password: "a secure password",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
|
||||||
http.post("*/api/v1/auth/login", () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
accessToken: "test",
|
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
|
||||||
http.get("*/api/v1/bank-cards/summary", () =>
|
|
||||||
|
|
||||||
HttpResponse.json(
|
|
||||||
{
|
|
||||||
cardId: null,
|
|
||||||
categories: [],
|
|
||||||
expenses: { currency: "RUB", value: "0" },
|
|
||||||
month: "2026-08",
|
|
||||||
net: { currency: "RUB", value: "0" },
|
|
||||||
refunds: { currency: "RUB", value: "0" },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
renderRoutes("/cards?month=2026-08");
|
|
||||||
await screen.findByRole("heading", { 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: "Вход" }));
|
|
||||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
|
||||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
|
||||||
"01.08.2026 – 31.08.2026",
|
|
||||||
);
|
|
||||||
expect(document.title).toBe("Карты — WealthLedger");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
|
||||||
server.use(
|
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
accessToken: "test-token",
|
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
renderRoutes("/accounts");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
level: 2,
|
|
||||||
name: "Нет инвестиционных счетов",
|
|
||||||
}),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+15
-23
@@ -1,5 +1,3 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
@@ -8,27 +6,23 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
|
import type { FC } from "react";
|
||||||
import { MemoryRouter, useNavigate } from "react-router";
|
import { MemoryRouter, useNavigate } from "react-router";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { ProtectedDocumentTitle } from "./DocumentTitle";
|
import { ProtectedDocumentTitle } from "./DocumentTitle";
|
||||||
|
|
||||||
const Navigation: FC = () => {
|
const Navigation: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => navigate("/accounts/all/income")} type="button">
|
<button type="button" onClick={() => navigate("/accounts/all/income")}>
|
||||||
Все счета
|
Все счета
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate(-1)}>
|
||||||
<button onClick={() => navigate(-1)} type="button">
|
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -41,7 +35,6 @@ const renderTitle = (path: string) =>
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ProtectedDocumentTitle />
|
<ProtectedDocumentTitle />
|
||||||
|
|
||||||
<Navigation />
|
<Navigation />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -54,7 +47,7 @@ beforeEach(() => {
|
|||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
accessToken: "test-token",
|
accessToken: "test-token",
|
||||||
user: { email: "user@example.com", id: "user-1" },
|
user: { id: "user-1", email: "user@example.com" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -62,19 +55,19 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("document titles", () => {
|
describe("document titles", () => {
|
||||||
it("updates a loading account name and follows account scope and history", async () => {
|
it("updates a loading account name and follows account scope and history", async () => {
|
||||||
const { promise: accountsReady, resolve: resolveAccounts }
|
let resolveAccounts!: () => void;
|
||||||
= Promise.withResolvers<void>();
|
const accountsReady = new Promise<void>((resolve) => {
|
||||||
|
resolveAccounts = resolve;
|
||||||
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", async () => {
|
http.get("*/api/v1/brokerage-accounts", async () => {
|
||||||
await accountsReady;
|
await accountsReady;
|
||||||
|
return HttpResponse.json([{ id: "a", name: "ИИС" }]);
|
||||||
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
|
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
|
||||||
expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
|
expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
|
||||||
await act(() => Promise.resolve(resolveAccounts()));
|
await act(async () => resolveAccounts());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
|
expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
|
||||||
);
|
);
|
||||||
@@ -92,7 +85,7 @@ describe("document titles", () => {
|
|||||||
async (kind, id, fallback, name) => {
|
async (kind, id, fallback, name) => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
|
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
|
||||||
HttpResponse.json(resource({ name })),
|
HttpResponse.json({ name }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderTitle(`/${kind}/${id}`);
|
renderTitle(`/${kind}/${id}`);
|
||||||
@@ -104,16 +97,15 @@ describe("document titles", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it("keeps a useful title when the instrument is unavailable", async () => {
|
it("keeps a useful title when the instrument is unavailable", async () => {
|
||||||
let isRequested = false;
|
let requested = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
|
http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
|
||||||
isRequested = true;
|
requested = true;
|
||||||
|
|
||||||
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
|
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderTitle("/stocks/missing");
|
renderTitle("/stocks/missing");
|
||||||
await waitFor(() => expect(isRequested).toBe(true));
|
await waitFor(() => expect(requested).toBe(true));
|
||||||
expect(document.title).toBe("Акция — WealthLedger");
|
expect(document.title).toBe("Акция — WealthLedger");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
import type { FC } from "react";
|
||||||
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";
|
||||||
@@ -11,9 +9,10 @@ import { useStock } from "@/entities/stock";
|
|||||||
|
|
||||||
type DocumentTitleProps = { title: string };
|
type DocumentTitleProps = { title: string };
|
||||||
|
|
||||||
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
const DocumentTitle: FC<DocumentTitleProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountDocumentTitleProps = {
|
type AccountDocumentTitleProps = {
|
||||||
@@ -24,36 +23,19 @@ 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"));
|
||||||
|
const section =
|
||||||
let section = t("portfolio.title");
|
props.section === "income"
|
||||||
|
? t("income.title")
|
||||||
switch (props.section ?? "") {
|
: props.section === "operations"
|
||||||
case "income": {
|
? t("ledger.title")
|
||||||
section = t("income.title");
|
: props.section === "events"
|
||||||
break;
|
? t("events.title")
|
||||||
}
|
: t("portfolio.title");
|
||||||
|
|
||||||
case "operations": {
|
|
||||||
section = t("ledger.title");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "events": {
|
|
||||||
section = t("events.title");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DocumentTitle title={`${section} — ${accountName}`} />;
|
return <DocumentTitle title={`${section} — ${accountName}`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,38 +44,31 @@ 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 {
|
const bond = useMatch("/bonds/:bondId");
|
||||||
account,
|
const stock = useMatch("/stocks/:stockId");
|
||||||
bond,
|
const card = useMatch("/cards/:cardId");
|
||||||
bonds,
|
const cards = useMatch("/cards");
|
||||||
card,
|
const bonds = useMatch("/bonds");
|
||||||
cards,
|
const stocks = useMatch("/stocks");
|
||||||
settings,
|
const settings = useMatch("/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 (
|
||||||
@@ -105,13 +80,21 @@ const ProtectedDocumentTitle: FC = () => {
|
|||||||
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
|
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
|
||||||
if (stock)
|
if (stock)
|
||||||
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
|
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
|
||||||
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
|
return (
|
||||||
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
|
<DocumentTitle
|
||||||
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
|
title={t(
|
||||||
|
cards
|
||||||
return settings
|
? "navigation.cards"
|
||||||
? <DocumentTitle title={t("navigation.settings")} />
|
: bonds
|
||||||
: <DocumentTitle title={t("navigation.accounts")} />;
|
? "navigation.bonds"
|
||||||
|
: stocks
|
||||||
|
? "navigation.stocks"
|
||||||
|
: settings
|
||||||
|
? "navigation.settings"
|
||||||
|
: "navigation.accounts",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ProtectedDocumentTitle };
|
export { ProtectedDocumentTitle };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user