From a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 19:20:32 +0300 Subject: [PATCH] refactor: address frontend review feedback --- .ai/conventions/typescript-react.md | 3 + .ai/conventions/ui.md | 16 +- .storybook/dependency-cruiser.cjs | 9 +- eslint.config.mjs | 4 +- .../OperationIcon/OperationIcon.module.css | 1 + .../icons/src/OperationIcon/OperationIcon.tsx | 132 ++++++------- .../src/OperationIcon/svg/BasketIcon.tsx | 40 ++++ .../icons/src/OperationIcon/svg/CarIcon.tsx | 40 ++++ .../OperationIcon/svg/CashWithdrawalIcon.tsx | 38 ++++ .../src/OperationIcon/svg/CoffeeIcon.tsx | 40 ++++ .../src/OperationIcon/svg/DepositIcon.tsx | 38 ++++ .../icons/src/OperationIcon/svg/DiceIcon.tsx | 42 +++++ .../src/OperationIcon/svg/DownloadIcon.tsx | 40 ++++ .../src/OperationIcon/svg/EducationIcon.tsx | 36 ++++ .../icons/src/OperationIcon/svg/GridIcon.tsx | 38 ++++ .../src/OperationIcon/svg/GrowthIcon.tsx | 36 ++++ .../icons/src/OperationIcon/svg/HomeIcon.tsx | 36 ++++ .../src/OperationIcon/svg/MedicalIcon.tsx | 32 ++++ .../icons/src/OperationIcon/svg/MusicIcon.tsx | 36 ++++ .../src/OperationIcon/svg/RefundIcon.tsx | 34 ++++ .../src/OperationIcon/svg/RepaymentIcon.tsx | 36 ++++ .../src/OperationIcon/svg/ServerIcon.tsx | 42 +++++ .../src/OperationIcon/svg/ShieldIcon.tsx | 34 ++++ .../src/OperationIcon/svg/ShoppingIcon.tsx | 34 ++++ .../src/OperationIcon/svg/SmokingIcon.tsx | 38 ++++ .../OperationIcon/svg/SubscriptionsIcon.tsx | 38 ++++ .../icons/src/OperationIcon/svg/TaxiIcon.tsx | 44 +++++ .../icons/src/OperationIcon/svg/TopupIcon.tsx | 36 ++++ .../src/OperationIcon/svg/TransferIcon.tsx | 34 ++++ .../src/OperationIcon/svg/TravelIcon.tsx | 32 ++++ .../icons/src/OperationIcon/svg/WifiIcon.tsx | 38 ++++ .../icons/src/OperationIcon/svg/WineIcon.tsx | 38 ++++ scripts/test-eslint.mjs | 24 ++- src/app/routes/AppRoutes/AllAccounts.test.tsx | 21 +-- src/app/routes/AppRoutes/AppRoutes.test.tsx | 3 +- .../routes/DocumentTitle/DocumentTitle.tsx | 8 +- .../RouteErrorBoundary.test.tsx | 8 +- .../RouteErrorBoundary/RouteErrorBoundary.tsx | 4 +- src/app/routes/RouteFallback.tsx | 4 +- src/app/routes/RouteGuards.tsx | 4 +- src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 4 +- src/entities/bank-card/api/banking.test.tsx | 3 - src/entities/bank-card/model/bank.ts | 8 + .../bank-card/model/form-validation.ts | 1 - .../bank-card/model/operation-appearance.ts | 176 ++++++------------ .../ui/BankCardFace/BankCardFace.stories.tsx | 34 ++-- .../ui/BankCardFace/BankCardFace.tsx | 8 +- .../ui/BankCardForm/BankCardFields.vm.ts | 69 +------ .../ui/BankCardForm/BankCardForm.test.tsx | 6 +- .../ui/BankCardForm/BankCardForm.view.tsx | 1 - .../ui/BankCardForm/BankCardForm.vm.ts | 3 - .../ui/BankCardForm/useOpeningDateField.ts | 49 +++++ .../ui/BankCardForm/usePaymentSystemField.ts | 27 +++ .../BankOperationIcon.stories.tsx | 4 +- .../BankOperationIcon.test.tsx | 5 +- .../ui/BankSummary/BankSummary.stories.tsx | 14 +- .../ui/BankSummary/BankSummary.view.tsx | 4 +- .../ui/CategoryManager/CategoryManager.tsx | 8 +- .../broker-operation/ui/LedgerEntryRow.tsx | 2 +- .../brokerage-account/model/display.ts | 1 - .../BrokerageAccountList.stories.tsx | 6 - .../BrokerageAccountList.test.tsx | 9 +- .../BrokerageAccountList.tsx | 8 +- .../api/useBrokerageEvents.test.tsx | 7 +- .../brokerage-event/ui/BrokerageEventCard.tsx | 2 +- .../BrokerageEventsPreview.stories.tsx | 10 +- .../BrokerageEventsPreview.tsx | 20 +- .../portfolio/api/resource-lifecycle.test.tsx | 5 +- src/entities/portfolio/model/display.ts | 3 - src/features/auth/model/auth.test.tsx | 6 +- .../ui/InstrumentPosition.view.tsx | 8 +- .../ui/InstrumentSection.view.tsx | 4 +- .../model/screen-sync.test.tsx | 36 ++-- .../tbank-connection/model/screen-sync.ts | 2 +- .../tbank-connection/model/sync.test.tsx | 6 +- .../TbankConnectionSection.test.tsx | 7 +- .../TbankConnectionSection.tsx | 8 +- .../TbankConnectionSectionLoaded.tsx | 4 +- .../TbankConnectionSectionLoaded.vm.ts | 1 - .../AccountEventsPage.filters.test.tsx | 2 - .../AccountEventsPage.test.tsx | 13 +- .../AccountEventsPage/AccountEventsPage.tsx | 8 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 4 +- .../AccountEventsPageEmpty.view.tsx | 4 +- .../AccountEventsPageLoaded.view.tsx | 8 +- .../AccountEventsPageLoading.view.tsx | 4 +- src/pages/account-income/model/period.ts | 3 +- .../AccountIncomePage.test.tsx | 77 +++----- .../AccountIncomePage/AccountIncomePage.tsx | 8 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 8 +- .../AccountIncomePageLoaded.view.tsx | 8 +- .../components/IncomeComparisons.view.tsx | 4 +- .../IncomePeriodForm.stories.tsx | 2 +- .../AccountOperationsPage.test.tsx | 21 +-- .../AccountOperationsPage.tsx | 8 +- .../AccountOperationsPage.vm.ts | 4 +- .../AccountOperationsPageLoaded.view.tsx | 12 +- .../AccountOperationsPageLoading.view.tsx | 4 +- .../ui/AccountPage/AccountPage.test.tsx | 154 ++++++--------- .../ui/AccountPage/AccountPage.vm.test.tsx | 32 ++-- .../account/ui/AccountPage/AccountPage.vm.ts | 8 +- .../components/AccountPageLoaded.view.tsx | 12 +- .../AccountPageLoading.view.tsx | 4 +- .../components/IncomeSummary.view.tsx | 4 +- .../PortfolioStructure.stories.tsx | 6 +- .../PortfolioStructure.test.tsx | 7 +- .../PortfolioStructure.view.tsx | 4 +- .../PortfolioStructure.vm.ts | 2 +- .../PositionCard/PositionCard.stories.tsx | 13 +- .../PositionCard/PositionCard.view.tsx | 4 +- .../components/PositionGroupCard.view.tsx | 8 +- .../components/PositionLogo.view.tsx | 4 +- .../components/RecentOperations.view.tsx | 20 +- .../ui/AccountPage/position-groups.test.tsx | 17 +- .../position-layout.browser.test.tsx | 3 +- .../ui/AccountsPage/AccountsPage.test.tsx | 5 +- src/pages/bond/ui/BondPage/BondPage.test.tsx | 18 +- src/pages/bond/ui/BondPage/BondPage.view.tsx | 20 +- src/pages/bond/ui/BondPage/BondPage.vm.ts | 7 +- src/pages/card/ui/CardPage/CardPage.test.tsx | 9 +- src/pages/card/ui/CardPage/CardPage.view.tsx | 8 +- src/pages/card/ui/CardPage/CardPage.vm.ts | 2 +- .../ui/CardPage/CardPagePresentation.vm.ts | 3 +- .../DeleteOperation/DeleteOperation.test.tsx | 3 - .../ui/OperationForm/OperationFields.vm.ts | 2 +- .../ui/OperationForm/OperationForm.test.tsx | 9 - .../ui/OperationForm/OperationForm.view.tsx | 4 +- .../card/ui/OperationForm/OperationForm.vm.ts | 6 +- .../cards/ui/CardsPage/CardsPage.test.tsx | 13 +- .../cards/ui/CardsPage/CardsPage.view.tsx | 4 +- .../stock/ui/StockPage/StockPage.test.tsx | 20 +- .../stock/ui/StockPage/StockPage.view.tsx | 8 +- src/pages/stock/ui/StockPage/StockPage.vm.ts | 10 +- .../StockPage/components/StockEvents.view.tsx | 4 +- src/shared/api/client.test.ts | 2 +- src/shared/lib/display.ts | 2 +- src/test/handlers.ts | 6 +- src/test/resource.ts | 30 ++- src/test/setup.ts | 1 - 139 files changed, 1549 insertions(+), 941 deletions(-) create mode 100644 packages/icons/src/OperationIcon/svg/BasketIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/CarIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/CashWithdrawalIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/CoffeeIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/DepositIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/DiceIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/DownloadIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/EducationIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/GridIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/GrowthIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/HomeIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/MedicalIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/MusicIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/RefundIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/RepaymentIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/ServerIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/ShieldIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/ShoppingIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/SmokingIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/SubscriptionsIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/TaxiIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/TopupIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/TransferIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/TravelIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/WifiIcon.tsx create mode 100644 packages/icons/src/OperationIcon/svg/WineIcon.tsx create mode 100644 src/entities/bank-card/model/bank.ts create mode 100644 src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts create mode 100644 src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts diff --git a/.ai/conventions/typescript-react.md b/.ai/conventions/typescript-react.md index 489c3df..3593ce5 100644 --- a/.ai/conventions/typescript-react.md +++ b/.ai/conventions/typescript-react.md @@ -26,6 +26,9 @@ JSX siblings и вокруг многострочных declarations/control-flo сложные 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 проверяет то, чего синтаксис не доказывает: diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index e7c691a..69db119 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -13,7 +13,13 @@ Mantine component и общую тему. Для выпадающего выбо Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры -иконки из model сущности. SVG декоративны по умолчанию; доступное имя action +иконки из model сущности. Каждый SVG хранится цельным декларативным +компонентом: не разбивай path-строки и не собирай геометрию в runtime. +Русские category names из API — данные для нормализованного сопоставления, +не ключи реестра иконок; неизвестные/переименованные категории имеют fallback. +Известные названия банков принадлежат enum `BankName` в bank-card model; +свободный ввод пользовательского названия остаётся строкой. +SVG декоративны по умолчанию; доступное имя action принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"` и доступное имя. Приложение и Storybook подключают CSS пакета. @@ -68,6 +74,14 @@ JSX props получают ссылки на функции, включая `onC адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain преобразование остаётся в model своего owner; shared не импортирует entities/pages. Closure над состоянием, mutations и refs является действием VM, а не shared helper. + +VM собирает связные предметные hooks, когда внутри смешиваются независимые +сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями +даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook +возвращает данные и действия своей ответственности, VM объединяет их для view. +Не группируй несвязанные функции только по префиксу `on*` и не создавай +passthrough hook над одним вызовом ради формального дробления. + Render-helper замени самостоятельным presentation component у того же owner. Отдельный state/section с самостоятельной responsibility выноси в diff --git a/.storybook/dependency-cruiser.cjs b/.storybook/dependency-cruiser.cjs index 347f1a1..b8b42d1 100644 --- a/.storybook/dependency-cruiser.cjs +++ b/.storybook/dependency-cruiser.cjs @@ -5,11 +5,16 @@ module.exports = { forbidden: [ ...base.forbidden, { - from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` }, + from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" }, name: "stories-must-not-reach-application-data", severity: "error", to: { - path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`, + path: [ + "^src/(shared/api|features/auth|app/routes|app/ui)/", + "^src/(entities|features)/[^/]+/api/", + "^src/pages/[^/]+/ui/.*Page[.]tsx$", + "node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/", + ], reachable: true, }, }, diff --git a/eslint.config.mjs b/eslint.config.mjs index edbfa82..2885a5a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -126,8 +126,8 @@ export default defineConfig( "error", { replacements: { props: false, ref: false } }, ], - // React, Mantine and Fetch accept null at their documented call boundaries. - "unicorn/no-null": ["error", { checkArguments: 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: { diff --git a/packages/icons/src/OperationIcon/OperationIcon.module.css b/packages/icons/src/OperationIcon/OperationIcon.module.css index 10e12ad..f986cd6 100644 --- a/packages/icons/src/OperationIcon/OperationIcon.module.css +++ b/packages/icons/src/OperationIcon/OperationIcon.module.css @@ -1,4 +1,5 @@ .icon { + flex-shrink: 0; overflow: visible; & path { diff --git a/packages/icons/src/OperationIcon/OperationIcon.tsx b/packages/icons/src/OperationIcon/OperationIcon.tsx index 198b036..1322db5 100644 --- a/packages/icons/src/OperationIcon/OperationIcon.tsx +++ b/packages/icons/src/OperationIcon/OperationIcon.tsx @@ -1,86 +1,72 @@ import type { FC } from "react"; -import { bindIconAnimation } from "../bind-icon-animation"; -import classes from "./OperationIcon.module.css"; +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 paths = { - basket: - "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0", - car: - "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4", - cashWithdrawal: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4", - coffee: - "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15", - deposit: - "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0", - dice: - "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01", - download: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3", - education: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9", - grid: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z", - growth: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5", - home: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", - medical: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z", - music: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2", - refund: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", - repayment: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4", - server: - "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3", - shield: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6", - shopping: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2", - smoking: - "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4", - subscriptions: - "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4", - taxi: - "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2", - topup: - "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5", - transfer: "M3 7h18l-4-4 M21 17H3l4 4", - travel: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z", - wifi: - "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01", - wine: - "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8", -} as const; +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 = { - animation?: string; color: string; - variant: keyof typeof paths; + variant: keyof typeof icons; }; const OperationIcon: FC = (props) => { - const { animation, color, variant } = props; - // eslint-disable-next-line security/detect-object-injection -- Animation keys select CSS module classes only. - const animationClass = animation ? classes[animation] : ""; + 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]; - // eslint-disable-next-line security/detect-object-injection -- Variant is a closed keyof the package geometry map. - const parts = paths[variant] - .split(" M") - .map((part, index) => ({ d: index ? `M${part}` : part, key: part })); - - return ( - - ); + return ; }; export { OperationIcon, type OperationIconProps }; diff --git a/packages/icons/src/OperationIcon/svg/BasketIcon.tsx b/packages/icons/src/OperationIcon/svg/BasketIcon.tsx new file mode 100644 index 0000000..e476e53 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/BasketIcon.tsx @@ -0,0 +1,40 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const BasketIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { BasketIcon }; diff --git a/packages/icons/src/OperationIcon/svg/CarIcon.tsx b/packages/icons/src/OperationIcon/svg/CarIcon.tsx new file mode 100644 index 0000000..c71a321 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/CarIcon.tsx @@ -0,0 +1,40 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const CarIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { CarIcon }; diff --git a/packages/icons/src/OperationIcon/svg/CashWithdrawalIcon.tsx b/packages/icons/src/OperationIcon/svg/CashWithdrawalIcon.tsx new file mode 100644 index 0000000..63b73dc --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/CashWithdrawalIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const CashWithdrawalIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { CashWithdrawalIcon }; diff --git a/packages/icons/src/OperationIcon/svg/CoffeeIcon.tsx b/packages/icons/src/OperationIcon/svg/CoffeeIcon.tsx new file mode 100644 index 0000000..26dc7c5 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/CoffeeIcon.tsx @@ -0,0 +1,40 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const CoffeeIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { CoffeeIcon }; diff --git a/packages/icons/src/OperationIcon/svg/DepositIcon.tsx b/packages/icons/src/OperationIcon/svg/DepositIcon.tsx new file mode 100644 index 0000000..c85ae3b --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/DepositIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const DepositIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { DepositIcon }; diff --git a/packages/icons/src/OperationIcon/svg/DiceIcon.tsx b/packages/icons/src/OperationIcon/svg/DiceIcon.tsx new file mode 100644 index 0000000..0388e9e --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/DiceIcon.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const DiceIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { DiceIcon }; diff --git a/packages/icons/src/OperationIcon/svg/DownloadIcon.tsx b/packages/icons/src/OperationIcon/svg/DownloadIcon.tsx new file mode 100644 index 0000000..dba47eb --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/DownloadIcon.tsx @@ -0,0 +1,40 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const DownloadIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { DownloadIcon }; diff --git a/packages/icons/src/OperationIcon/svg/EducationIcon.tsx b/packages/icons/src/OperationIcon/svg/EducationIcon.tsx new file mode 100644 index 0000000..b080fa6 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/EducationIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const EducationIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { EducationIcon }; diff --git a/packages/icons/src/OperationIcon/svg/GridIcon.tsx b/packages/icons/src/OperationIcon/svg/GridIcon.tsx new file mode 100644 index 0000000..ab3ef21 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/GridIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const GridIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { GridIcon }; diff --git a/packages/icons/src/OperationIcon/svg/GrowthIcon.tsx b/packages/icons/src/OperationIcon/svg/GrowthIcon.tsx new file mode 100644 index 0000000..fb2ff72 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/GrowthIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const GrowthIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { GrowthIcon }; diff --git a/packages/icons/src/OperationIcon/svg/HomeIcon.tsx b/packages/icons/src/OperationIcon/svg/HomeIcon.tsx new file mode 100644 index 0000000..7ffda51 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/HomeIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const HomeIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { HomeIcon }; diff --git a/packages/icons/src/OperationIcon/svg/MedicalIcon.tsx b/packages/icons/src/OperationIcon/svg/MedicalIcon.tsx new file mode 100644 index 0000000..d9df27a --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/MedicalIcon.tsx @@ -0,0 +1,32 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const MedicalIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { MedicalIcon }; diff --git a/packages/icons/src/OperationIcon/svg/MusicIcon.tsx b/packages/icons/src/OperationIcon/svg/MusicIcon.tsx new file mode 100644 index 0000000..c7774f4 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/MusicIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const MusicIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { MusicIcon }; diff --git a/packages/icons/src/OperationIcon/svg/RefundIcon.tsx b/packages/icons/src/OperationIcon/svg/RefundIcon.tsx new file mode 100644 index 0000000..882208f --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/RefundIcon.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const RefundIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { RefundIcon }; diff --git a/packages/icons/src/OperationIcon/svg/RepaymentIcon.tsx b/packages/icons/src/OperationIcon/svg/RepaymentIcon.tsx new file mode 100644 index 0000000..f9ea4d0 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/RepaymentIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const RepaymentIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { RepaymentIcon }; diff --git a/packages/icons/src/OperationIcon/svg/ServerIcon.tsx b/packages/icons/src/OperationIcon/svg/ServerIcon.tsx new file mode 100644 index 0000000..de821f4 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/ServerIcon.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const ServerIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { ServerIcon }; diff --git a/packages/icons/src/OperationIcon/svg/ShieldIcon.tsx b/packages/icons/src/OperationIcon/svg/ShieldIcon.tsx new file mode 100644 index 0000000..b02a98a --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/ShieldIcon.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const ShieldIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { ShieldIcon }; diff --git a/packages/icons/src/OperationIcon/svg/ShoppingIcon.tsx b/packages/icons/src/OperationIcon/svg/ShoppingIcon.tsx new file mode 100644 index 0000000..4dd818b --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/ShoppingIcon.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const ShoppingIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { ShoppingIcon }; diff --git a/packages/icons/src/OperationIcon/svg/SmokingIcon.tsx b/packages/icons/src/OperationIcon/svg/SmokingIcon.tsx new file mode 100644 index 0000000..8e09412 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/SmokingIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const SmokingIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { SmokingIcon }; diff --git a/packages/icons/src/OperationIcon/svg/SubscriptionsIcon.tsx b/packages/icons/src/OperationIcon/svg/SubscriptionsIcon.tsx new file mode 100644 index 0000000..87f09f7 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/SubscriptionsIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const SubscriptionsIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { SubscriptionsIcon }; diff --git a/packages/icons/src/OperationIcon/svg/TaxiIcon.tsx b/packages/icons/src/OperationIcon/svg/TaxiIcon.tsx new file mode 100644 index 0000000..c72bd19 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/TaxiIcon.tsx @@ -0,0 +1,44 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const TaxiIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { TaxiIcon }; diff --git a/packages/icons/src/OperationIcon/svg/TopupIcon.tsx b/packages/icons/src/OperationIcon/svg/TopupIcon.tsx new file mode 100644 index 0000000..11328a1 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/TopupIcon.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const TopupIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { TopupIcon }; diff --git a/packages/icons/src/OperationIcon/svg/TransferIcon.tsx b/packages/icons/src/OperationIcon/svg/TransferIcon.tsx new file mode 100644 index 0000000..ce41992 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/TransferIcon.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const TransferIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { TransferIcon }; diff --git a/packages/icons/src/OperationIcon/svg/TravelIcon.tsx b/packages/icons/src/OperationIcon/svg/TravelIcon.tsx new file mode 100644 index 0000000..4248c2f --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/TravelIcon.tsx @@ -0,0 +1,32 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const TravelIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { TravelIcon }; diff --git a/packages/icons/src/OperationIcon/svg/WifiIcon.tsx b/packages/icons/src/OperationIcon/svg/WifiIcon.tsx new file mode 100644 index 0000000..05b259e --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/WifiIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const WifiIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { WifiIcon }; diff --git a/packages/icons/src/OperationIcon/svg/WineIcon.tsx b/packages/icons/src/OperationIcon/svg/WineIcon.tsx new file mode 100644 index 0000000..794c404 --- /dev/null +++ b/packages/icons/src/OperationIcon/svg/WineIcon.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; + +import type { IconProps } from "../../types"; + +import { bindIconAnimation } from "../../bind-icon-animation"; +import classes from "../OperationIcon.module.css"; + +const WineIcon: FC = (props) => { + const { size = 24, ...svgProps } = props; + + return ( + + ); +}; + +export { WineIcon }; diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 65fc8f5..3d383d1 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -130,7 +130,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as assert.equal(story.rules["react-refresh/only-export-components"], undefined); }); -test("all recommended plugin rules remain enabled in their intended scopes", async () => { +test("recommended plugin rules retain the explicit null-contract exception", async () => { const eslint = new ESLint(); const source = "src/shared/api/problem-details.ts"; const component = "packages/ui/src/EntryCard/EntryCard.tsx"; @@ -175,6 +175,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy 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}`); } } @@ -316,3 +322,19 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () => assert.equal(again.output, undefined); assert.equal(again.errorCount, 0); }); + +test("compact JSX and contract nulls need no local suppressions", async () => { + const eslint = new ESLint({ fix: true }); + const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; + const code = "import type { FC } from 'react'; type Props = { active: boolean }; const Example: FC = (props) => props.active ? :
;\nexport { Example };"; + const [fixed] = await eslint.lintText(code, { filePath }); + assert.equal(fixed.errorCount, 0); + assert.match(fixed.output ?? code, /\? :
/); + + const [nullable] = await eslint.lintText( + "export const value: string | null = null;", + { filePath: "src/shared/api/problem-details.ts" }, + ); + + assert.equal(nullable.errorCount, 0); +}); diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 7807b85..62a849a 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -29,8 +29,6 @@ const accounts = [ { id: "b", name: "ИИС" }, ]; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const position: Portfolio["positions"][number] = { absoluteReturn: null, @@ -45,7 +43,6 @@ const position: Portfolio["positions"][number] type: "STOCK", }; -/* eslint-enable unicorn/no-null */ const portfolio: Portfolio = { cashBalances: [], isStale: false, @@ -55,8 +52,6 @@ const portfolio: Portfolio = { totalsByCurrency: [{ currency: "RUB", value: "1200" }], }; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const receipt: BrokerageOperation = { accruedInterest: null, @@ -76,11 +71,8 @@ const receipt: BrokerageOperation type: "OPERATION_TYPE_COUPON", }; -/* eslint-enable unicorn/no-null */ const items = accounts.map(account => ({ ...receipt, account })); -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const analytics: IncomeAnalytics = { accountId: "all", @@ -118,7 +110,6 @@ const analytics: IncomeAnalytics yearComparison: null, }; -/* eslint-enable unicorn/no-null */ const CurrentLocation: FC = () => { const location = useLocation(); @@ -162,7 +153,7 @@ beforeEach(() => { HttpResponse.json( resource( accounts.map( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + account => ({ ...account, @@ -170,7 +161,6 @@ beforeEach(() => { portfolioSummary: null, type: "BROKERAGE", }), - /* eslint-enable unicorn/no-null */ ), ), ), @@ -182,7 +172,7 @@ beforeEach(() => { "*/api/v1/brokerage-accounts/:accountId/operations", ({ params }) => HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: params.accountId, @@ -191,7 +181,6 @@ beforeEach(() => { nextCursor: null, observedAt: receipt.occurredAt, }, - /* eslint-enable unicorn/no-null */ ), ), ), @@ -204,7 +193,7 @@ beforeEach(() => { accountId: "all", calculatedAt: receipt.occurredAt, events: accounts.map( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + account => ({ account, @@ -219,7 +208,6 @@ beforeEach(() => { ticker: "BOND", type: "coupon", }), - /* eslint-enable unicorn/no-null */ ), incomeTotalsByCurrency: [], }), @@ -278,7 +266,7 @@ describe("All accounts routes", () => { requests.push(url); return HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: params.accountId, @@ -294,7 +282,6 @@ describe("All accounts routes", () => { nextCursor: url.searchParams.has("cursor") ? null : "next", observedAt: receipt.occurredAt, }, - /* eslint-enable unicorn/no-null */ ), ); }, diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index 1706372..cbc1698 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -104,7 +104,7 @@ describe("AppRoutes", () => { ), http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards/summary", () => - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { cardId: null, @@ -114,7 +114,6 @@ describe("AppRoutes", () => { net: { currency: "RUB", value: "0" }, refunds: { currency: "RUB", value: "0" }, }, - /* eslint-enable unicorn/no-null */ ), ), ); diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index ee243f8..2c3caa4 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -106,12 +106,8 @@ const ProtectedDocumentTitle: FC = () => { if (stocks) return ; return settings - ? ( - - ) - : ( - - ); + ? + : ; }; export { ProtectedDocumentTitle }; diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx index 8218ddf..c1c7cd4 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx @@ -8,10 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary"; const session = { isAuthenticated: false }; vi.mock("@/features/auth", () => ({ - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + useAuth: () => ({ user: session.isAuthenticated ? {} : null }), - /* eslint-enable unicorn/no-null */ + })); const ThrowingRoute: FC = () => { @@ -25,9 +25,7 @@ const HealthyRoute: FC = () => { ? ( "healthy route" ) - : ( - - )} + : }
); }; diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index 6fe81ae..bb63f50 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -34,9 +34,7 @@ class Boundary extends Component { render() { return this.state.hasError - ? ( - - ) + ? : ( this.props.children ); diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx index 77f5bf9..a2b71cf 100644 --- a/src/app/routes/RouteFallback.tsx +++ b/src/app/routes/RouteFallback.tsx @@ -79,9 +79,7 @@ const LazyPage: FC = (props) => { ? ( children ) - : ( - {children} - )} + : {children}} ); }; diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index b740d94..2d57663 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -13,9 +13,7 @@ const Protected: FC = (props) => { if (!ready) return ; return user - ? ( - <>{children} - ) + ? <>{children} : ( { return ; return settings - ? ( - - ) + ? : undefined; }; diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index 436c37a..33e87a6 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -19,8 +19,6 @@ import { useBankSummary, } from "./queries"; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const card: BankCard = { archivedAt: null, @@ -35,7 +33,6 @@ const card: BankCard version: 1, }; -/* eslint-enable unicorn/no-null */ const transfer: CreateBankOperation = { amount: { currency: "RUB", value: "90071992547409.93" }, cardId: "a", diff --git a/src/entities/bank-card/model/bank.ts b/src/entities/bank-card/model/bank.ts new file mode 100644 index 0000000..a015d21 --- /dev/null +++ b/src/entities/bank-card/model/bank.ts @@ -0,0 +1,8 @@ +enum BankName { + AlfaBank = "Альфа-Банк", + Sberbank = "Сбербанк", + TBank = "Т-Банк", + Vtb = "ВТБ", +} + +export { BankName }; diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 0d6415a..56f11db 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -32,7 +32,6 @@ function bankToday() { function moneyCents(value: string): bigint | null { const normalized = value.trim().replace(",", "."); if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) - // eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. return null; const [whole, fraction = ""] = normalized diff --git a/src/entities/bank-card/model/operation-appearance.ts b/src/entities/bank-card/model/operation-appearance.ts index b54e169..177aa02 100644 --- a/src/entities/bank-card/model/operation-appearance.ts +++ b/src/entities/bank-card/model/operation-appearance.ts @@ -2,129 +2,68 @@ import type { OperationIconProps } from "@wealthledger/icons"; import type { BankOperation } from "../api/queries"; -type OperationIcon = { - animation?: string; - color: string; - variant: OperationIconProps["variant"]; -}; +const categoryIcons: { + appearance: OperationIconProps; + name: string; +}[] = [ + { appearance: { color: "grape", variant: "wine" }, name: "Алкоголь" }, + { appearance: { color: "green", variant: "deposit" }, name: "Вклады" }, + { appearance: { color: "orange", variant: "home" }, name: "Жильё" }, + { appearance: { color: "red", variant: "medical" }, name: "Здоровье" }, + { + appearance: { color: "green", variant: "growth" }, + name: "Инвестиции и накопления", + }, + { appearance: { color: "orange", variant: "coffee" }, name: "Кафе" }, + { appearance: { color: "blue", variant: "education" }, name: "Образование" }, + { appearance: { color: "blue", variant: "transfer" }, name: "Переводы" }, + { + appearance: { color: "orange", variant: "repayment" }, + name: "Погашение кредита", + }, + { + appearance: { color: "grape", variant: "subscriptions" }, + name: "Подписки", + }, + { appearance: { color: "grape", variant: "shopping" }, name: "Покупки" }, + { appearance: { color: "green", variant: "basket" }, name: "Продукты" }, + { appearance: { color: "blue", variant: "travel" }, name: "Путешествия" }, + { appearance: { color: "grape", variant: "music" }, name: "Развлечения" }, + { appearance: { color: "blue", variant: "wifi" }, name: "Связь и интернет" }, + { + appearance: { color: "green", variant: "cashWithdrawal" }, + name: "Снятие наличных", + }, + { appearance: { color: "grape", variant: "dice" }, name: "Ставки" }, + { appearance: { color: "green", variant: "shield" }, name: "Страхование" }, + { appearance: { color: "orange", variant: "smoking" }, name: "Табак" }, + { appearance: { color: "yellow", variant: "taxi" }, name: "Такси" }, + { appearance: { color: "blue", variant: "car" }, name: "Транспорт" }, + { + appearance: { color: "grape", variant: "server" }, + name: "Хостинг и ИИ-сервисы", + }, + { + appearance: { color: "blue", variant: "download" }, + name: "Цифровые покупки", + }, +]; -const categoryIcons: Record = { - "алкоголь": { - animation: "wine", - color: "grape", - variant: "wine", - }, - "вклады": { - animation: "deposit", - color: "green", - variant: "deposit", - }, - "жилье": { color: "orange", variant: "home" }, - "здоровье": { color: "red", variant: "medical" }, - "инвестиции и накопления": { - animation: "growth", - color: "green", - variant: "growth", - }, - "кафе": { - animation: "coffee", - color: "orange", - variant: "coffee", - }, - "образование": { - color: "blue", - variant: "education", - }, - "переводы": { - animation: "transfer", - color: "blue", - variant: "transfer", - }, - "погашение кредита": { - animation: "repayment", - color: "orange", - variant: "repayment", - }, - "подписки": { - color: "grape", - variant: "subscriptions", - }, - "покупки": { - color: "grape", - variant: "shopping", - }, - "продукты": { - animation: "basket", - color: "green", - variant: "basket", - }, - "путешествия": { - color: "blue", - variant: "travel", - }, - "развлечения": { - color: "grape", - variant: "music", - }, - "связь и интернет": { - animation: "wifi", - color: "blue", - variant: "wifi", - }, - "снятие наличных": { - color: "green", - variant: "cashWithdrawal", - }, - "ставки": { - color: "grape", - variant: "dice", - }, - "страхование": { - color: "green", - variant: "shield", - }, - "табак": { - color: "orange", - variant: "smoking", - }, - "такси": { - color: "yellow", - variant: "taxi", - }, - "транспорт": { - animation: "car", - color: "blue", - variant: "car", - }, - "хостинг и ии-сервисы": { - animation: "server", - color: "grape", - variant: "server", - }, - "цифровые покупки": { - color: "blue", - variant: "download", - }, -}; - -const otherIcon: OperationIcon = { - animation: "grid", +const otherIcon: OperationIconProps = { color: "gray", variant: "grid", }; const operationIcons: Record< Exclude, - OperationIcon + OperationIconProps > = { refund: { color: "teal", variant: "refund" }, topup: { - animation: "topup", color: "green", variant: "topup", }, transfer: { - animation: "transfer", color: "blue", variant: "transfer", }, @@ -135,17 +74,20 @@ function bankOperationAppearance( categoryName?: null | string, ) { if (kind !== "expense") - // eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. + // eslint-disable-next-line security/detect-object-injection -- Operation kinds are a finite API union. return operationIcons[kind]; - const name = categoryName - ?.trim() - .toLocaleLowerCase("ru-RU") - .replaceAll("ё", "е"); + const name = normalizeCategoryName(categoryName ?? ""); - return name && Object.hasOwn(categoryIcons, name) - ? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. - : otherIcon; + const category = categoryIcons.find( + item => normalizeCategoryName(item.name) === name, + ); + + return category?.appearance ?? otherIcon; +} + +function normalizeCategoryName(name: string) { + return name.trim().toLocaleLowerCase("ru-RU").replaceAll("ё", "е"); } export { bankOperationAppearance }; diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx index d1b56df..8cdc965 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx @@ -2,17 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { SimpleGrid } from "@mantine/core"; +import { BankName } from "../../model/bank"; import { BankCardFace } from "./BankCardFace"; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const meta = { args: { card: { archivedAt: null, balance: { currency: "RUB", value: "128450.75" }, - bankName: "Сбербанк", + bankName: BankName.Sberbank, id: "fictional", lastFour: "4829", name: "Для повседневных покупок", @@ -25,7 +24,7 @@ const meta component: BankCardFace, tags: ["autodocs"], title: "Product/BankCards/CardAppearance", - } satisfies /* eslint-enable unicorn/no-null */ Meta; + } satisfies Meta; export default meta; @@ -35,33 +34,28 @@ export const Banks: Story = { render: arguments_ => ( {[ - "Сбербанк", - "Т-Банк", - "Альфа-Банк", - "ВТБ", - // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + BankName.Sberbank, + BankName.TBank, + BankName.AlfaBank, + BankName.Vtb, null, ].map((bankName, index) => ( ))} ), }; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + export const MissingDetails: Story = { args: { @@ -73,7 +67,7 @@ export const MissingDetails: Story }, }, }; -/* eslint-enable unicorn/no-null */ + export const LongArchived: Story = { args: { card: { diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx index baeadd2..58d3070 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx @@ -22,9 +22,7 @@ const BankCardFace: FC = (props) => {
{card.bankName || t("bank.card")} - {card.archivedAt && ( - {t("bank.archived")} - )} + {card.archivedAt && {t("bank.archived")}}
diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index 92643a3..45c20a3 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -84,7 +84,7 @@ function useCardPageVm() { otherCard: item.kind === "transfer" ? otherCardId - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, title, tone: isOutgoing ? ("negative" as const) : ("positive" as const), value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`, diff --git a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts index da43fdc..ee1b7ad 100644 --- a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts +++ b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts @@ -25,7 +25,7 @@ function useCardPagePresentationVm(vm: CardPageVm) { if (pending.scope !== scope || vm.history.isError) { pendingPageRef.current - // eslint-disable-next-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. + = null; return; @@ -40,7 +40,6 @@ function useCardPagePresentationVm(vm: CardPageVm) { if (delta) window.scrollBy({ behavior: "instant", top: delta }); } - // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. pendingPageRef.current = null; }, [ scope, diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx index a0ee863..406da07 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx @@ -12,8 +12,6 @@ import { server } from "@/test/server"; import { DeleteOperation } from "./DeleteOperation"; -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const transfer: BankOperation = { amount: { currency: "RUB", value: "100" }, @@ -33,7 +31,6 @@ const transfer: BankOperation version: 1, }; -/* eslint-enable unicorn/no-null */ describe("DeleteOperation", () => { it("names both sides and requires a deliberate fresh version after conflict", async () => { configureAuthClient({ diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts index 4194599..53cc5dd 100644 --- a/src/pages/card/ui/OperationForm/OperationFields.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts @@ -59,7 +59,7 @@ function useOperationFieldsVm(props: OperationFieldsProps) { const dateParser: ComponentProps["dateParser"] = value => props.validDate(props.dateInputValue(value)) ? props.dateInputValue(value) - : null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled dateField as null. + : null; const onDateBlur: ComponentProps["onBlur"] = (event) => { dateField.onChange(props.dateInputValue(event.currentTarget.value)); diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index f2868c9..1729b71 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -20,8 +20,6 @@ import { OperationForm } from "./OperationForm"; const money = (value: string) => ({ currency: "RUB", value }); -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const card: BankCard = { archivedAt: null, @@ -36,7 +34,6 @@ const card: BankCard version: 1, }; -/* eslint-enable unicorn/no-null */ async function select(label: RegExp, value: RegExp | string) { fireEvent.click(screen.getByRole("combobox", { name: label })); fireEvent.click(await screen.findByRole("option", { name: value })); @@ -229,7 +226,6 @@ describe("OperationForm", () => { expect(body).not.toHaveProperty("categoryId"); }); it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => { - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ const purchase: BankOperation = { amount: money("500"), @@ -249,7 +245,6 @@ describe("OperationForm", () => { version: 1, }; - /* eslint-enable unicorn/no-null */ server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ @@ -313,7 +308,6 @@ describe("OperationForm", () => { }); }); it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => { - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ const refund: BankOperation = { amount: money("500"), @@ -333,7 +327,6 @@ describe("OperationForm", () => { version: 2, }; - /* eslint-enable unicorn/no-null */ const purchase = { ...refund, availableRefund: money("0"), @@ -394,7 +387,6 @@ describe("OperationForm", () => { }); it("keeps edit draft and stale version until explicit adoption after conflict", async () => { - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ const operation: BankOperation = { amount: money("100"), @@ -414,7 +406,6 @@ describe("OperationForm", () => { version: 1, }; - /* eslint-enable unicorn/no-null */ let readVersion = 1; const bodies: Record[] = []; server.use( diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index dab2e57..159b423 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -228,9 +228,7 @@ const OperationFormView: FC = (props) => { <> - {props.loadingExpenses && ( - {t("bank.loadingHistory")} - )} + {props.loadingExpenses && {t("bank.loadingHistory")}} {props.emptyExpenses && {t("bank.noPurchases")}} diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index 568a427..24be589 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -165,13 +165,13 @@ const useOperationFormVm = (props: OperationFormProps) => { return { categoriesError: categories.isError ? categories.error - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, categoriesOpen, closeCategories: () => setCategoriesOpen(false), error, expensesError: expenses.isError ? expenses.error - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, methods, onCategoryCreated: (id: string) => { methods.setValue("categoryId", id); @@ -182,7 +182,7 @@ const useOperationFormVm = (props: OperationFormProps) => { retrySource: () => void sourceQuery.refetch(), sourceError: sourceQuery.isError ? sourceQuery.error - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, view: { categoriesOpen, editing: Boolean(props.operation), diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx index 4b7fb7c..28a683a 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx @@ -33,7 +33,7 @@ function renderCards(isFail = false, hasCategoryFailure = false) { isFail ? HttpResponse.error() : HttpResponse.json([]), ), http.get("*/api/v1/bank-cards/summary", () => - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { cardId: null, @@ -43,7 +43,6 @@ function renderCards(isFail = false, hasCategoryFailure = false) { net: money("0"), refunds: money("0"), }, - /* eslint-enable unicorn/no-null */ ), ), ); @@ -119,7 +118,7 @@ describe("CardsPage", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), ); expect(submitted[0]).toEqual(submitted[1]); - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + expect(submitted[0]).toMatchObject( { bankName: null, @@ -127,7 +126,6 @@ describe("CardsPage", () => { name: "Повседневная", openingBalance: money("-92233720368547758.08"), }, - /* eslint-enable unicorn/no-null */ ); }); it("uses archive only for card list and keeps all-card summary including negative net", async () => { @@ -137,8 +135,6 @@ describe("CardsPage", () => { http.get("*/api/v1/bank-cards", ({ request }) => { requests.push(new URL(request.url).search); - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - return HttpResponse.json([ { archivedAt: "2026-09-01", @@ -151,7 +147,7 @@ describe("CardsPage", () => { openingDate: "2026-01-01", version: 1, }, - /* eslint-enable unicorn/no-null */ + ]); }), ); @@ -175,8 +171,6 @@ describe("CardsPage", () => { http.get("*/api/v1/bank-cards/summary", ({ request }) => { requests.push(new URL(request.url).search); - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - return HttpResponse.json( { cardId: null, @@ -185,7 +179,6 @@ describe("CardsPage", () => { net: money("0"), refunds: money("0"), }, - /* eslint-enable unicorn/no-null */ ); }), ); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index db4171b..1b1ea0b 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -125,9 +125,7 @@ const CardsPageView: FC = (props) => { )} - {vm.cards.data && vm.cards.isFetching && ( - {t("bank.refreshing")} - )} + {vm.cards.data && vm.cards.isFetching && {t("bank.refreshing")}} diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index e634c85..b645944 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -29,8 +29,6 @@ const LocationProbe = () => { const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13"; const money = (value: string) => ({ currency: "RUB", value }); -/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - const stock = { currency: "RUB", @@ -44,7 +42,6 @@ const stock ticker: "TEST", }; -/* eslint-enable unicorn/no-null */ const position = { absoluteReturn: money("1000"), accounts: [ @@ -124,7 +121,7 @@ function setup( ); if (params.section === "candles") return HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { candles: [], @@ -132,14 +129,13 @@ function setup( historyFrom: null, observedAt: quote.observedAt, }, - /* eslint-enable unicorn/no-null */ ), ); if (params.section === "company") return options.failCompany ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { description: "Описание компании", @@ -155,12 +151,11 @@ function setup( observedAt: quote.observedAt, unavailable: [], }, - /* eslint-enable unicorn/no-null */ ), ); return HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { dividends: [ @@ -188,7 +183,6 @@ function setup( positionIsStale: false, to: "2027-09-10", }, - /* eslint-enable unicorn/no-null */ ), ); }), @@ -200,7 +194,7 @@ function setup( return HttpResponse.json({ code: cursorError }, { status: 400 }); return HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "all", @@ -225,7 +219,6 @@ function setup( nextCursor: isNext ? null : "next", observedAt: quote.observedAt, }, - /* eslint-enable unicorn/no-null */ ), ); }), @@ -264,7 +257,6 @@ function setup( describe("stock page", () => { it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => { - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ setup( { metrics: [ @@ -312,7 +304,6 @@ describe("stock page", () => { }, ], }, - /* eslint-enable unicorn/no-null */ ); expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible(); for (const value of [ @@ -468,9 +459,8 @@ describe("stock page", () => { it.each([ [ "INVALID_OPERATIONS_CURSOR", - // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. null, - null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + null, ], ["TBANK_UNAVAILABLE", "next", "observe"], ])( diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 61b86c4..71fa99d 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -146,9 +146,7 @@ const StockPageView: FC = (props) => { ); } else { - companyMetricsContent = ( - {t("stock.noFundamentals")} - ); + companyMetricsContent = {t("stock.noFundamentals")}; } } @@ -219,9 +217,7 @@ const StockPageView: FC = (props) => { )} - {stock.lot !== null && ( - {t("stock.lot", { value: stock.lot })} - )} + {stock.lot !== null && {t("stock.lot", { value: stock.lot })}} {!!vm.company && ( diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index 49ecd53..fa3bd6d 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -92,7 +92,7 @@ function useStockPageVm() { const averageLine = average && average.currency === candles.data?.currency ? Number(average.value) - : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null; const companyData = company.data; const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; @@ -101,13 +101,13 @@ function useStockPageVm() { averageLine: averageLine !== null && Number.isFinite(averageLine) ? averageLine - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, candles: candles.data, candlesState: section(candles, sync.retry), canonicalTo: stock.data?.slug && stock.data.slug !== stockId ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` - : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : null, chartData, code: apiErrorCode(stock.error), company: companyData, @@ -120,12 +120,12 @@ function useStockPageVm() { logoName: item.logoName ?? stock.data?.logoName - // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + ?? null, ticker: item.ticker ?? stock.data?.ticker - // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + ?? null, })) ?? [], filter, diff --git a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx b/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx index 060f027..780c6d1 100644 --- a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx @@ -162,9 +162,7 @@ const StockDividends: FC = (props) => { ); } else { - pastDividendsContent = ( - {t("stock.noDividends")} - ); + pastDividendsContent = {t("stock.noDividends")}; } return ( diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index ac80480..e588b64 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -55,7 +55,7 @@ describe("apiClient authentication", () => { it("does not attach Bearer auth to authentication endpoints", async () => { let authorization: null | string - // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + = null; server.use( diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 0295165..1e178de 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -80,7 +80,7 @@ function instrumentLogoUrl(logoName?: null | string): null | string { return stem ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png` - : null; // eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. + : null; } export { diff --git a/src/test/handlers.ts b/src/test/handlers.ts index 82f3a3f..a3e1595 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -19,10 +19,9 @@ const handlers = [ ), http.get("*/api/v1/auth/me", () => HttpResponse.json(authentication.user)), http.get("*/api/v1/tbank-connection", () => - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { connected: false, connectedAt: null }, - /* eslint-enable unicorn/no-null */ ), ), http.put("*/api/v1/tbank-connection", () => @@ -49,7 +48,7 @@ const handlers = [ ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", @@ -58,7 +57,6 @@ const handlers = [ nextCursor: null, observedAt: "2026-08-14T00:00:00Z", }, - /* eslint-enable unicorn/no-null */ ), ), ), diff --git a/src/test/resource.ts b/src/test/resource.ts index 227d73b..f6d7204 100644 --- a/src/test/resource.ts +++ b/src/test/resource.ts @@ -4,23 +4,19 @@ type Meta = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"]; function resource(data: T, meta: Partial = {}) { - /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - return ( - { - data, - meta: { - availability: data === null ? "missing" : "complete", - dataRevision: "1", - freshness: "fresh", - lastSuccessfulAt: "2026-08-14T00:00:00Z", - resourceKey: "fixture", - stateVersion: "1", - sync: { errorCode: null, id: null, state: "idle" }, - ...meta, - } satisfies Meta, - } - /* eslint-enable unicorn/no-null */ - ); + return { + data, + meta: { + availability: data === null ? "missing" : "complete", + dataRevision: "1", + freshness: "fresh", + lastSuccessfulAt: "2026-08-14T00:00:00Z", + resourceKey: "fixture", + stateVersion: "1", + sync: { errorCode: null, id: null, state: "idle" }, + ...meta, + } satisfies Meta, + }; } export { resource }; diff --git a/src/test/setup.ts b/src/test/setup.ts index 24b5009..da99809 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -40,7 +40,6 @@ Object.defineProperty(globalThis, "matchMedia", { matches: false, media: query, onchange: - // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. null, removeEventListener: () => {}, removeListener: () => {},