refactor: enforce frontend readability and UI boundaries #43

Merged
ksv741 merged 2 commits from codex/frontend-view-guardrails into develop 2026-09-28 19:40:24 +03:00
139 changed files with 1549 additions and 941 deletions
Showing only changes of commit a4046dbe87 - Show all commits
+3
View File
@@ -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 проверяет то, чего синтаксис не доказывает:
+15 -1
View File
@@ -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 выноси в
+7 -2
View File
@@ -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: [
Outdated
Review

можно ли упростить ? без использования String.raw ?

можно ли упростить ? без использования 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)/",
],
reachable: true,
},
},
+2 -2
View File
@@ -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: {
@@ -1,4 +1,5 @@
.icon {
flex-shrink: 0;
overflow: visible;
& path {
@@ -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,
Outdated
Review

не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме

не нужно так, svg должны быть целостными, а не разбиты по кускам, чтобы собираться в рантайме
server: ServerIcon,
shield: ShieldIcon,
shopping: ShoppingIcon,
smoking: SmokingIcon,
subscriptions: SubscriptionsIcon,
taxi: TaxiIcon,
topup: TopupIcon,
transfer: TransferIcon,
travel: TravelIcon,
wifi: WifiIcon,
wine: WineIcon,
};
type OperationIconProps = {
animation?: string;
color: string;
variant: keyof typeof paths;
variant: keyof typeof icons;
};
const OperationIcon: FC<OperationIconProps> = (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 (
<svg
aria-hidden="true"
className={`${classes.icon} ${animationClass}`}
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{parts.map(part => (
<path d={part.d} key={part.key} pathLength={1} />
))}
</svg>
);
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
};
export { OperationIcon, type OperationIconProps };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const BasketIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.basket}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
<path d="M8 9l4-6 4 6" pathLength={1} />
<path d="M9 13v3" pathLength={1} />
<path d="M15 13v3" pathLength={1} />
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
</svg>
);
};
export { BasketIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CarIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.car}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
<path d="M8 16h8" pathLength={1} />
<path d="M6 11h12m-6-4v4" pathLength={1} />
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
</svg>
);
};
export { CarIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CashWithdrawalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10V3h16v7" pathLength={1} />
<path d="M7 7h10v14H7V7Z" pathLength={1} />
<path d="M7 11h10" pathLength={1} />
<path d="M10 16h4" pathLength={1} />
</svg>
);
};
export { CashWithdrawalIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CoffeeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.coffee}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M3 22h15" pathLength={1} />
</svg>
);
};
export { CoffeeIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DepositIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.deposit}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
</svg>
);
};
export { DepositIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DiceIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
<path d="M7 7h.01" pathLength={1} />
<path d="M17 7h.01" pathLength={1} />
<path d="M12 12h.01" pathLength={1} />
<path d="M7 17h.01" pathLength={1} />
<path d="M17 17h.01" pathLength={1} />
</svg>
);
};
export { DiceIcon };
@@ -0,0 +1,40 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DownloadIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v13H3V3Z" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
<path d="M12 16v5" pathLength={1} />
<path d="M12 6v7" pathLength={1} />
<path d="M9 10l3 3 3-3" pathLength={1} />
</svg>
);
};
export { DownloadIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const EducationIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
<path d="M22 8v9" pathLength={1} />
</svg>
);
};
export { EducationIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GridIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.grid}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 4h6v6H4V4Z" pathLength={1} />
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
</svg>
);
};
export { GridIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GrowthIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.growth}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" pathLength={1} />
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
<path d="M16 5h5v5" pathLength={1} />
</svg>
);
};
export { GrowthIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const HomeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 11l9-8 9 8" pathLength={1} />
<path d="M5 10v11h14V10" pathLength={1} />
<path d="M9 21v-8h6v8" pathLength={1} />
</svg>
);
};
export { HomeIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MedicalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
</svg>
);
};
export { MedicalIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MusicIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 9l11-2" pathLength={1} />
</svg>
);
};
export { MusicIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RefundIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M8 4L3 9l5 5" pathLength={1} />
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
</svg>
);
};
export { RefundIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RepaymentIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.repayment}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Z" pathLength={1} />
<path d="M3 9h18" pathLength={1} />
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
</svg>
);
};
export { RepaymentIcon };
@@ -0,0 +1,42 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ServerIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.server}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v7H3V3Z" pathLength={1} />
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
<path d="M7 6.5h.01" pathLength={1} />
<path d="M7 17.5h.01" pathLength={1} />
<path d="M15 6.5h3" pathLength={1} />
<path d="M15 17.5h3" pathLength={1} />
</svg>
);
};
export { ServerIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShieldIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
<path d="M8 12l3 3 5-6" pathLength={1} />
</svg>
);
};
export { ShieldIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShoppingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
</svg>
);
};
export { ShoppingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SmokingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
<path d="M16 14v5" pathLength={1} />
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
</svg>
);
};
export { SmokingIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SubscriptionsIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
<path d="M20 3v4h-4" pathLength={1} />
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
<path d="M4 21v-4h4" pathLength={1} />
</svg>
);
};
export { SubscriptionsIcon };
@@ -0,0 +1,44 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TaxiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10l2-6h12l2 6" pathLength={1} />
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
<path d="M6 18v3" pathLength={1} />
<path d="M18 18v3" pathLength={1} />
<path d="M6 14h2" pathLength={1} />
<path d="M16 14h2" pathLength={1} />
<path d="M10 4V2h4v2" pathLength={1} />
</svg>
);
};
export { TaxiIcon };
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TopupIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.topup}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
</svg>
);
};
export { TopupIcon };
@@ -0,0 +1,34 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TransferIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.transfer}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7h18l-4-4" pathLength={1} />
<path d="M21 17H3l4 4" pathLength={1} />
</svg>
);
};
export { TransferIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TravelIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
</svg>
);
};
export { TravelIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WifiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wifi}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
<path d="M12 19h.01" pathLength={1} />
</svg>
);
};
export { WifiIcon };
@@ -0,0 +1,38 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WineIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wine}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
<path d="M12 13v8" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
</svg>
);
};
export { WineIcon };
+23 -1
View File
@@ -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) => 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);
});
+4 -17
View File
@@ -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 */
),
);
},
+1 -2
View File
@@ -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 */
),
),
);
@@ -106,12 +106,8 @@ const ProtectedDocumentTitle: FC = () => {
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
return settings
? (
<DocumentTitle title={t("navigation.settings")} />
)
: (
<DocumentTitle title={t("navigation.accounts")} />
);
? <DocumentTitle title={t("navigation.settings")} />
: <DocumentTitle title={t("navigation.accounts")} />;
};
export { ProtectedDocumentTitle };
1
@@ -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"
)
: (
<ThrowingRoute />
)}
: <ThrowingRoute />}
</div>
);
};
@@ -34,9 +34,7 @@ class Boundary extends Component<BoundaryProps, State> {
render() {
return this.state.hasError
? (
<RouteErrorFallback authenticated={this.props.authenticated} />
)
? <RouteErrorFallback authenticated={this.props.authenticated} />
: (
this.props.children
);
+1 -3
View File
@@ -79,9 +79,7 @@ const LazyPage: FC<LazyPageProps> = (props) => {
? (
children
)
: (
<RouteTransition>{children}</RouteTransition>
)}
: <RouteTransition>{children}</RouteTransition>}
</Suspense>
);
};
+1 -3
View File
@@ -13,9 +13,7 @@ const Protected: FC<PropsWithChildren> = (props) => {
if (!ready) return <RouteSkeleton variant="shell" />;
return user
? (
<>{children}</>
)
? <>{children}</>
: (
<Navigate
replace
+1 -3
View File
@@ -88,9 +88,7 @@ const AppBreadcrumbs: FC = () => {
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
return settings
? (
<BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
)
? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
: undefined;
};
@@ -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
Review

слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило

слишком часто отключаем это правило, его нужно или совсем выключить или же правильно писать код, чтобы не дизейблить правило
= {
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",
+8
View File
@@ -0,0 +1,8 @@
enum BankName {
AlfaBank = "Альфа-Банк",
Sberbank = "Сбербанк",
TBank = "Т-Банк",
Vtb = "ВТБ",
}
export { BankName };
@@ -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
@@ -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: "Жильё" },
Review

эту логику нужно переработать, потому что svg должны быть целостными, а не собираться в рантайме
также ключи в виде русских слов это порнография

эту логику нужно переработать, потому что svg должны быть целостными, а не собираться в рантайме также ключи в виде русских слов это порнография
{ 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<string, OperationIcon> = {
"алкоголь": {
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<BankOperation["kind"], "expense">,
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 };
@@ -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<typeof BankCardFace>;
} satisfies Meta<typeof BankCardFace>;
export default meta;
@@ -35,33 +34,28 @@ export const Banks: Story = {
render: arguments_ => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{[
"Сбербанк",
"Т-Банк",
"Альфа-Банк",
"ВТБ",
// 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,
Review

названия банков должны быть вынесены в отдельный enum

названия банков должны быть вынесены в отдельный enum
].map((bankName, index) => (
<BankCardFace
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
card={
{
...arguments_.card,
bankName,
paymentSystem:
card={{
...arguments_.card,
bankName,
paymentSystem:
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
(["mir", "mastercard", "visa", "mir", null] as const)[index]
?? null,
}
/* eslint-enable unicorn/no-null */
}
}}
key={bankName ?? "unknown"}
/>
))}
</SimpleGrid>
),
};
/* 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: {
@@ -22,9 +22,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
<div className={classes.top}>
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
{card.archivedAt && (
<span className={classes.archive}>{t("bank.archived")}</span>
)}
{card.archivedAt && <span className={classes.archive}>{t("bank.archived")}</span>}
</div>
<div aria-hidden="true" className={classes.hardware}>
@@ -46,9 +44,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
{card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && (
<span aria-hidden="true" className={classes.circles} />
)}
{card.paymentSystem === "mastercard" && <span aria-hidden="true" className={classes.circles} />}
{t(`bank.paymentSystems.${card.paymentSystem}`)}
</span>
@@ -1,11 +1,10 @@
import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
import type { BankCardValues } from "../../model/form.schema";
import { useOpeningDateField } from "./useOpeningDateField";
import { usePaymentSystemField } from "./usePaymentSystemField";
function useBankCardFieldsVm() {
const {
Review

возможно лучше использовать внутри дополнительные хуки, в которых будет инкапсулировано поведение по смыслу, например все on* действия вынести в отдельный хук, который в хуке useBankCardFieldsVm будет просто отдавать наружу детали и т.д

возможно так будет понятнее логика + сделать в виде guardrails

возможно лучше использовать внутри дополнительные хуки, в которых будет инкапсулировано поведение по смыслу, например все on* действия вынести в отдельный хук, который в хуке useBankCardFieldsVm будет просто отдавать наружу детали и т.д возможно так будет понятнее логика + сделать в виде guardrails
@@ -15,62 +14,10 @@ function useBankCardFieldsVm() {
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
const openingDate = useOpeningDateField(control);
const paymentSystem = usePaymentSystemField(control);
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange: (value: null | string) => void = value =>
paymentSystemField.onChange(value ?? "");
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange: (value: null | string) => void = value =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register,
t,
};
return { ...openingDate, ...paymentSystem, errors, register, t };
}
export { useBankCardFieldsVm };
@@ -12,8 +12,6 @@ import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card: BankCard
= {
archivedAt: null,
@@ -28,7 +26,6 @@ const card: BankCard
version: 1,
};
/* eslint-enable unicorn/no-null */
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
@@ -75,7 +72,7 @@ describe("BankCardForm", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(body).toEqual(
{
bankName: null,
@@ -84,7 +81,6 @@ describe("BankCardForm", () => {
paymentSystem: null,
version: 1,
},
/* eslint-enable unicorn/no-null */
);
});
it("loads and saves the selected payment system", async () => {
@@ -57,7 +57,6 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
placeholder={t("bank.paymentSystemUnknown")}
value={
paymentSystemField.value
// eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
|| null
}
/>
@@ -49,16 +49,13 @@ function useBankCardFormVm(props: BankCardFormProps) {
const metadata = {
bankName:
values.bankName
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
lastFour:
values.lastFour
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
name: values.name,
paymentSystem:
values.paymentSystem
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
};
@@ -0,0 +1,49 @@
import type { ComponentProps } from "react";
import type { Control } from "react-hook-form";
import { DateInput } from "@mantine/dates";
import { useController } from "react-hook-form";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useOpeningDateField(control: Control<BankCardValues>) {
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null;
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange = (value: null | string) =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
openingDateField,
openingDateValue,
};
}
export { useOpeningDateField };
@@ -0,0 +1,27 @@
import type { Control } from "react-hook-form";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
function usePaymentSystemField(control: Control<BankCardValues>) {
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
}
export { usePaymentSystemField };
@@ -10,7 +10,9 @@ const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
<OperationIcon
{...bankOperationAppearance(props.kind, props.categoryName)}
/>
);
const meta = {
@@ -8,7 +8,9 @@ const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
<OperationIcon
{...bankOperationAppearance(props.kind, props.categoryName)}
/>
);
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
@@ -28,7 +30,6 @@ it("matches normalized category names, falls back after renaming and distinguish
for (const categoryName of [
"Моя категория",
"constructor",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
@@ -4,8 +4,6 @@ import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const meta
= {
args: {
@@ -62,14 +60,14 @@ const meta
},
tags: ["autodocs"],
title: "Product/BankCards/Summary",
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankSummaryView>;
} satisfies Meta<typeof BankSummaryView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Loaded: Story = { name: "Расходы по категориям" };
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const RefundMonth: Story
= {
args: {
@@ -93,8 +91,7 @@ export const RefundMonth: Story
},
name: "Возврат покупки прошлого месяца",
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Empty: Story
= {
args: {
@@ -109,7 +106,7 @@ export const Empty: Story
},
name: "Известные нули",
};
/* eslint-enable unicorn/no-null */
export const Loading: Story = {
args: { data: undefined, loading: true },
name: "Начальная загрузка",
@@ -122,7 +119,7 @@ export const CachedError: Story = {
args: { error: true },
name: "Сохранённые суммы при ошибке обновления",
};
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const LongAmounts: Story
= {
args: {
@@ -145,4 +142,3 @@ export const LongAmounts: Story
},
name: "Точный итог длиннее предела отдельного ввода",
};
/* eslint-enable unicorn/no-null */
@@ -109,9 +109,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Group>
)}
{props.data && props.fetching && (
<Text role="status">{t("bank.refreshing")}</Text>
)}
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
</Stack>
);
};
@@ -88,9 +88,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
{category.isDefault && <Badge>{t("bank.defaultCategory")}</Badge>}
</Group>
<Button onClick={category.onRename} variant="subtle">
@@ -100,9 +98,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
);
})}
{categories.isLoading && (
<Text role="status">{t("bank.loading")}</Text>
)}
{categories.isLoading && <Text role="status">{t("bank.loading")}</Text>}
{categories.isError && (
<BankFeedback
@@ -34,7 +34,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }),
instrument,
props.withDetails
? null // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
? null
: operation.account?.name,
]
.filter(Boolean)
@@ -45,7 +45,6 @@ function brokerageAccountCards(
return {
account,
positionCounts:
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
null,
source,
summary: { state: "unavailable" },
@@ -64,8 +64,6 @@ export const Available: Story = {
},
};
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Unavailable: Story
= {
args: {
@@ -83,7 +81,6 @@ export const Unavailable: Story
},
},
};
/* eslint-enable unicorn/no-null */
export const Empty: Story = {
args: {
@@ -193,8 +190,6 @@ export const LargeValues: Story = {
name: "Длинные суммы",
};
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const IndependentFreshness: Story
= {
args: {
@@ -236,4 +231,3 @@ export const IndependentFreshness: Story
},
name: "Независимая свежесть и ошибка сводок",
};
/* eslint-enable unicorn/no-null */
@@ -29,7 +29,7 @@ describe("BrokerageAccountList", () => {
],
},
type: "BROKERAGE",
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
id: "account-2",
@@ -38,10 +38,10 @@ describe("BrokerageAccountList", () => {
portfolioSummary: null,
type: "BROKERAGE",
},
/* eslint-enable unicorn/no-null */ {
{
id: "account-3",
name: "Пустой",
openedOn: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 2,
@@ -84,7 +84,7 @@ describe("BrokerageAccountList", () => {
it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards(
["fresh", "stale"].map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id =>
({
id,
@@ -97,7 +97,6 @@ it("shows portfolio summary freshness independently for each account", () => {
},
type: "BROKERAGE" as const,
}),
/* eslint-enable unicorn/no-null */
),
[
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
@@ -105,13 +105,9 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
</Text>
)}
{source?.sync.state === "failed" && (
<Text size="xs">{t("accounts.refreshError")}</Text>
)}
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
{source?.freshness === "stale" && (
<Text size="xs">{t("investment.stale")}</Text>
)}
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
</Stack>
{!!positionCounts && (
@@ -37,7 +37,7 @@ describe("useBrokerageEvents", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
@@ -56,7 +56,6 @@ describe("useBrokerageEvents", () => {
},
],
},
/* eslint-enable unicorn/no-null */
),
),
),
@@ -67,7 +66,7 @@ describe("useBrokerageEvents", () => {
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(result.current.data?.events).toEqual([
{
date: "2026-09-01",
@@ -80,7 +79,7 @@ describe("useBrokerageEvents", () => {
ticker: "SBER",
type: "dividend",
},
/* eslint-enable unicorn/no-null */
]);
});
});
@@ -19,7 +19,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
date,
event.dateKind === "registry"
? t("events.dateKind.registry")
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
: null,
event.isin,
event.account?.name,
]
@@ -6,8 +6,6 @@ import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const syntheticEvent
= {
date: "2026-10-15",
@@ -20,7 +18,7 @@ const syntheticEvent
projectedAmount: { currency: "RUB", value: "850.50" },
ticker: "DEMO",
type: "coupon",
} satisfies /* eslint-enable unicorn/no-null */ BrokerageEvent;
} satisfies BrokerageEvent;
const eventsPath = "/accounts/storybook-account/events";
@@ -121,8 +119,6 @@ export const Empty: Story = {
},
};
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const MissingAmount: Story
= {
args: {
@@ -142,9 +138,6 @@ export const MissingAmount: Story
},
},
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const LongEvent: Story
= {
@@ -165,4 +158,3 @@ export const LongEvent: Story
},
name: "Длинное название на узком экране",
};
/* eslint-enable unicorn/no-null */
@@ -46,31 +46,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!events.refreshError && (
<Text role="status">{t("accounts.refreshError")}</Text>
)}
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
)}
{events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => (
<Skeleton height={48} key={placeholder} radius="sm" />
))}
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{events.state === "error" && (
<Alert color="red">{t("events.recentError")}</Alert>
)}
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
{events.state === "empty" && (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
)}
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
{events.state === "partial" && (
<Text role="status">{t("investment.partial")}</Text>
)}
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial")
&& events.items.map(event => (
@@ -56,7 +56,7 @@ describe("persistent resource query lifecycle", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
@@ -89,14 +89,13 @@ describe("persistent resource query lifecycle", () => {
expect(result.current.status).toBe("pending");
expect(result.current.isPending).toBe(true);
expect(result.current.isLoading).toBe(false);
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(result.current.transport).toEqual(
{
error: null,
fetchStatus: "idle",
status: "success",
},
/* eslint-enable unicorn/no-null */
);
expect(result.current.meta?.availability).toBe("missing");
});
-3
View File
@@ -9,15 +9,12 @@ function formatShare(
denominator: Money | null,
): null | string {
if (!numerator || !denominator)
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
return null;
const total = Number(denominator.value);
if (!Number.isFinite(total) || total <= 0)
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
return null;
const percent = (Number(numerator.value) / total) * PERCENT_SCALE;
if (!Number.isFinite(percent))
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
return null;
const rounded = new Intl.NumberFormat("ru-RU", {
+3 -3
View File
@@ -30,7 +30,7 @@ function createAuthWrapper(
describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", 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(
@@ -234,12 +234,12 @@ describe("AuthProvider", () => {
});
expect(
result.current.user?.id
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
?? null,
).toBe(
loginAfterLogout
? "b"
: null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
: null,
);
expect(order).toEqual([
"refresh-start",
@@ -30,9 +30,7 @@ const InstrumentPosition: FC<InstrumentPositionProps> = (props) => {
<InstrumentSection state={vm.positionState} title={t("stock.position")}>
{!!vm.position && (
<Stack gap="md">
{!!vm.position.isStale && (
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
)}
{!!vm.position.isStale && <Alert color="orange">{t("portfolio.staleWarning")}</Alert>}
<div className={`${classes.metrics} ${classes.holding}`}>
<div>
@@ -103,9 +101,7 @@ const InstrumentPosition: FC<InstrumentPositionProps> = (props) => {
{account.account.name}
</Anchor>
)
: (
<Text>{t("stock.portfolio")}</Text>
)}
: <Text>{t("stock.portfolio")}</Text>}
<div>
<Text c="dimmed" size="xs">
@@ -82,9 +82,7 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.state.partial && (
<Text role="status">{t("investment.partial")}</Text>
)}
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
@@ -135,12 +135,11 @@ describe("page-owned screen synchronization", () => {
expect(modes).toEqual(
cached
? [
null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
null,
"observe",
]
: [null, "observe"], // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
: [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
@@ -316,7 +315,7 @@ describe("page-owned screen synchronization", () => {
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
expect(
queryClient
@@ -357,8 +356,6 @@ describe("page-owned screen synchronization", () => {
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return HttpResponse.json(
{
errorCode: null,
@@ -369,7 +366,6 @@ describe("page-owned screen synchronization", () => {
scope: { screen: "accounts" },
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
);
}),
);
@@ -397,14 +393,14 @@ describe("page-owned screen synchronization", () => {
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
vi.useFakeTimers();
try {
await act(() => vi.advanceTimersByTimeAsync(120_000));
expect(polls).toBe(1);
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
unmount();
await vi.advanceTimersByTimeAsync(120_000);
@@ -429,10 +425,10 @@ describe("page-owned screen synchronization", () => {
resource(
isCompleted
? []
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable API fixture.
: null,
{
stateVersion: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
sync: { errorCode: null, id: "history", state: "running" },
},
);
@@ -445,8 +441,6 @@ describe("page-owned screen synchronization", () => {
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return HttpResponse.json(
{
errorCode: null,
@@ -461,7 +455,6 @@ describe("page-owned screen synchronization", () => {
scope: null,
state: "running",
},
/* eslint-enable unicorn/no-null */
);
}),
);
@@ -493,7 +486,7 @@ describe("page-owned screen synchronization", () => {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
@@ -515,12 +508,11 @@ describe("page-owned screen synchronization", () => {
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
[],
{
sync: { errorCode: null, id: "revoked", state: "running" },
},
/* eslint-enable unicorn/no-null */
),
),
),
@@ -550,7 +542,7 @@ describe("page-owned screen synchronization", () => {
const modes: (null | string)[] = [];
const dependency = resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
[],
{
resourceKey: "operations",
@@ -561,7 +553,6 @@ describe("page-owned screen synchronization", () => {
state: isObserved ? "idle" : "running",
},
},
/* eslint-enable unicorn/no-null */
).meta;
server.use(
@@ -574,7 +565,7 @@ describe("page-owned screen synchronization", () => {
resource(
isObserved
? []
: null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
: null,
{
dependencies: [
{
@@ -588,7 +579,7 @@ describe("page-owned screen synchronization", () => {
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
@@ -599,7 +590,6 @@ describe("page-owned screen synchronization", () => {
scope: null,
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
),
),
);
@@ -615,7 +605,7 @@ describe("page-owned screen synchronization", () => {
);
await waitFor(() => expect(result.current.data).toEqual([]));
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
});
@@ -206,7 +206,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
= null;
}
};
@@ -48,8 +48,6 @@ describe("sync connection and session boundaries", () => {
started += 1;
await blocked;
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return HttpResponse.json(
{
...accepted,
@@ -59,7 +57,6 @@ describe("sync connection and session boundaries", () => {
resources: [],
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
);
}),
);
@@ -103,13 +100,12 @@ describe("sync connection and session boundaries", () => {
if (boundary === "connection")
commitTbankConnectionBoundary(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
queryClient,
{
connected: false,
connectedAt: null,
},
/* eslint-enable unicorn/no-null */
);
else {
generation += 1;
@@ -77,7 +77,7 @@ function renderSection(client: QueryClient) {
function seededClient() {
const client = createTestQueryClient();
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
client.setQueryData(brokerageAccountsQueryKey, [
{
id: "account-1",
@@ -85,7 +85,7 @@ function seededClient() {
openedOn: null,
type: "BROKERAGE",
},
/* eslint-enable unicorn/no-null */
]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" });
@@ -157,10 +157,9 @@ describe("TbankConnectionSection", () => {
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
/* 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 */
);
}),
);
@@ -12,12 +12,8 @@ const TbankConnectionSection: FC = () => {
if (viewModel.state === "loading") return <TbankConnectionLoading />;
return viewModel.state === "error"
? (
<TbankConnectionError retry={viewModel.retry} />
)
: (
<TbankConnectionSectionLoaded {...viewModel} />
);
? <TbankConnectionError retry={viewModel.retry} />
: <TbankConnectionSectionLoaded {...viewModel} />;
};
export { TbankConnectionSection };
@@ -129,9 +129,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Stack>
<Text>{t("tbank.deleteText")}</Text>
{remove.isError && (
<Alert color="red">{t("tbank.requestError")}</Alert>
)}
{remove.isError && <Alert color="red">{t("tbank.requestError")}</Alert>}
<Button
color="red"
@@ -59,7 +59,6 @@ function useTbankConnectionSectionLoadedVm(
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt:
// eslint-disable-next-line unicorn/no-null -- The disconnected API snapshot requires a null connection timestamp.
null,
});
setIsDeleteOpened(false);
@@ -100,7 +100,6 @@ describe("Upcoming event date filters", () => {
return HttpResponse.json(
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
@@ -109,7 +108,6 @@ describe("Upcoming event date filters", () => {
state: "failed",
},
},
/* eslint-enable unicorn/no-null */
),
);
}),
@@ -37,7 +37,7 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: {
@@ -52,7 +52,7 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
} as /* eslint-enable unicorn/no-null */ never,
} as never,
);
});
@@ -73,7 +73,6 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: undefined,
@@ -84,7 +83,7 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
} as /* eslint-enable unicorn/no-null */ never,
} as never,
);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
@@ -95,7 +94,6 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: {
@@ -135,7 +133,7 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
} as /* eslint-enable unicorn/no-null */ never,
} as never,
);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
@@ -183,7 +181,6 @@ describe("AccountEventsPage", () => {
});
it("shows a structural busy state before events are available", () => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: undefined,
@@ -194,7 +191,7 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
} as /* eslint-enable unicorn/no-null */ never,
} as never,
);
renderPage();
@@ -12,12 +12,8 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error"
? (
<AccountEventsPageError {...viewModel} />
)
: (
<AccountEventsPageLoaded {...viewModel} />
);
? <AccountEventsPageError {...viewModel} />
: <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -41,11 +41,11 @@ function useAccountEventsPageViewModel(
const appliedFrom = isValidUrlPeriod
? urlFrom
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
: null;
const appliedTo = isValidUrlPeriod
? urlTo
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
: null;
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
@@ -6,9 +6,7 @@ import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
);
return <StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />;
};
export { AccountEventsPageEmpty };
@@ -74,13 +74,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
{t("investment.refresh")}
</Button>
{!!props.partial && (
<Alert role="status">{t("investment.partial")}</Alert>
)}
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.refreshing && (
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
)}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
@@ -27,9 +27,7 @@ const AccountEventsPageLoading: FC = () => {
<Skeleton height={36} width={108} />
</Group>
{skeletonRowIds.map(id => (
<Skeleton height={82} key={id} radius="md" />
))}
{skeletonRowIds.map(id => <Skeleton height={82} key={id} radius="md" />)}
</LoadingSkeleton>
);
};
+1 -2
View File
@@ -37,14 +37,13 @@ function moscowDate(value: Date): string {
function parseDraftDate(value: string): null | string {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
// eslint-disable-next-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
return null;
const [day, month, year] = value.split(".", DATE_PART_COUNT);
const date = `${year}-${month}-${day}`;
return isDate(date)
? date
: null; // eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
: null;
}
export {
@@ -38,8 +38,6 @@ import {
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const analytics
= {
accountId: "account-1",
@@ -81,7 +79,7 @@ const analytics
},
totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
} satisfies /* eslint-enable unicorn/no-null */ IncomeAnalytics;
} satisfies IncomeAnalytics;
function comparison(currency: string) {
return {
@@ -93,28 +91,24 @@ function comparison(currency: string) {
}
function entry(): IncomeAnalytics["entries"][number] {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return (
{
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
/* eslint-enable unicorn/no-null */
);
return {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
function money(currency: string, value: string) {
@@ -211,7 +205,6 @@ describe("AccountIncomePage", () => {
return HttpResponse.json(
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
@@ -220,7 +213,6 @@ describe("AccountIncomePage", () => {
state: "failed",
},
},
/* eslint-enable unicorn/no-null */
),
);
},
@@ -434,7 +426,6 @@ describe("AccountIncomePage", () => {
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const response
= {
...analytics,
@@ -464,7 +455,6 @@ describe("AccountIncomePage", () => {
],
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -607,37 +597,31 @@ describe("income comparison display", () => {
[
"10",
"0",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"0",
"0",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"10",
"-0.00",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"10",
"-100",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"9".repeat(400),
"1",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"1",
"9".repeat(400),
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
@@ -656,7 +640,7 @@ describe("income comparison display", () => {
const result = display?.comparison?.values[0];
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
@@ -863,7 +847,7 @@ describe("income period and snapshot boundary", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
@@ -874,7 +858,6 @@ describe("income period and snapshot boundary", () => {
scope: null,
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -998,8 +981,6 @@ describe("income period and snapshot boundary", () => {
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const previous
= {
...resource(null).meta,
@@ -1012,7 +993,6 @@ it("maps comparison notices by role regardless of dependency order or resource k
},
};
/* eslint-enable unicorn/no-null */
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
@@ -1041,10 +1021,10 @@ it.each([false, true])(
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ ...analytics, previous: null, yearComparison: null },
/* eslint-enable unicorn/no-null */ {
{
dependencies: legacy
? [
resource(null).meta,
@@ -1073,22 +1053,21 @@ it.each([false, true])(
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
role =>
({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
/* eslint-enable unicorn/no-null */
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ ...analytics, previous: null, yearComparison: null },
/* eslint-enable unicorn/no-null */ { dependencies },
{ dependencies },
),
),
),
@@ -1116,7 +1095,7 @@ it("keeps complete previous comparison while year source failed without inventin
{
...resource(analytics).meta,
role: "previous" as const,
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
...resource(null).meta,
@@ -1127,7 +1106,7 @@ it("keeps complete previous comparison while year source failed without inventin
state: "failed" as const,
},
},
/* eslint-enable unicorn/no-null */ {
{
...resource(analytics).meta,
role: "selected" as const,
},
@@ -12,12 +12,8 @@ const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return viewModel.state === "error"
? (
<AccountIncomePageError {...viewModel} />
)
: (
<AccountIncomePageLoaded {...viewModel} />
);
? <AccountIncomePageError {...viewModel} />
: <AccountIncomePageLoaded {...viewModel} />;
};
export { AccountIncomePage };
@@ -161,7 +161,6 @@ function comparisonDisplay(
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return {
@@ -220,11 +219,9 @@ function formatIncomeChange(
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return new Intl.NumberFormat("ru-RU", {
@@ -272,7 +269,7 @@ function toIncomeDisplayModel(
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
@@ -286,11 +283,9 @@ function toIncomeDisplayModel(
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
@@ -309,7 +304,6 @@ function toIncomeDisplayModel(
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
}
@@ -35,12 +35,8 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
}
return analytics.totalsByCurrency.length > 0
? (
<IncomeSummary totals={analytics.totalsByCurrency} />
)
: (
<StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />
);
? <IncomeSummary totals={analytics.totalsByCurrency} />
: <StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />;
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
@@ -34,9 +34,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
<Title order={2}>{props.title}</Title>
{props.comparison
? (
<Comparison {...props.comparison} title={props.title} />
)
? <Comparison {...props.comparison} title={props.title} />
: (
<Paper className={classes.comparisonNotice} p="sm" radius="md">
<Text aria-live="polite" c="gray.7" role="status" size="sm">
@@ -25,7 +25,7 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
const submitted = isSubmitSuccessful
? methods.getValues()
: null; // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
: null;
return (
<FormProvider {...methods}>
@@ -24,8 +24,6 @@ import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation: BrokerageOperation
= {
accruedInterest: null,
@@ -44,7 +42,6 @@ const operation: BrokerageOperation
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
};
/* eslint-enable unicorn/no-null */
const Navigation: FC = () => {
const location = useLocation();
@@ -322,10 +319,9 @@ describe("AccountOperationsPage", () => {
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ hasNext: false, items: [], nextCursor: null },
/* eslint-enable unicorn/no-null */
),
),
),
@@ -368,7 +364,7 @@ describe("AccountOperationsPage", () => {
searches.push(url.search);
return HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
@@ -377,7 +373,6 @@ describe("AccountOperationsPage", () => {
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
observedAt: "2026-08-14T00:00:00Z",
},
/* eslint-enable unicorn/no-null */
),
);
},
@@ -411,7 +406,7 @@ describe("AccountOperationsPage", () => {
searches.push(url.search);
return HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
@@ -420,7 +415,6 @@ describe("AccountOperationsPage", () => {
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
},
/* eslint-enable unicorn/no-null */
),
);
},
@@ -451,7 +445,7 @@ describe("AccountOperationsPage", () => {
searches.push(url.search);
return HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
@@ -460,7 +454,6 @@ describe("AccountOperationsPage", () => {
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
},
/* eslint-enable unicorn/no-null */
),
);
},
@@ -508,7 +501,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
return HttpResponse.json(
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
@@ -517,7 +509,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
state: "failed",
},
},
/* eslint-enable unicorn/no-null */
),
);
},
@@ -559,7 +550,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
server.use(
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",
@@ -567,7 +558,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [],
nextCursor: null,
},
/* eslint-enable unicorn/no-null */ { availability: "partial" },
{ availability: "partial" },
),
),
),
@@ -15,12 +15,8 @@ const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
}
return viewModel.state === "error"
? (
<AccountOperationsPageError {...viewModel} />
)
: (
<AccountOperationsPageLoaded {...viewModel} />
);
? <AccountOperationsPageError {...viewModel} />
: <AccountOperationsPageLoaded {...viewModel} />;
};
export { AccountOperationsPage };
@@ -54,11 +54,11 @@ const useAccountOperationsPageVm = (
const appliedFrom = isValidUrlPeriod
? urlFrom
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
: null;
const appliedTo = isValidUrlPeriod
? urlTo
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
: null;
const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
@@ -50,9 +50,7 @@ const AccountOperationsPageLoadedOperations: FC<
return;
}
return (
<StateView text={t("ledger.emptyText")} title={t("ledger.emptyTitle")} />
);
return <StateView text={t("ledger.emptyText")} title={t("ledger.emptyTitle")} />;
};
const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
@@ -91,15 +89,11 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
{t("investment.refresh")}
</Button>
{!!props.partial && (
<Alert role="status">{t("investment.partial")}</Alert>
)}
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
{!!props.refreshing && (
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
)}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
@@ -28,9 +28,7 @@ const AccountOperationsPageLoading: FC = () => {
</Group>
<Stack gap="xs">
{skeletonRowIds.map(id => (
<Skeleton height={64} key={id} radius="md" />
))}
{skeletonRowIds.map(id => <Skeleton height={64} key={id} radius="md" />)}
</Stack>
</LoadingSkeleton>
);
@@ -67,8 +67,6 @@ function renderPage(ui: React.ReactNode) {
);
}
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
@@ -103,7 +101,6 @@ const portfolio: Portfolio
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
};
/* eslint-enable unicorn/no-null */
const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty",
@@ -146,8 +143,6 @@ describe("AccountPage states", () => {
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position
= {
...portfolio.positions[0],
@@ -158,7 +153,6 @@ describe("AccountPage states", () => {
type,
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
@@ -483,7 +477,6 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -514,7 +507,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -528,7 +520,6 @@ describe("AccountPage states", () => {
it("groups stock supporting metrics into quiet single lines", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -547,7 +538,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -563,7 +553,6 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -606,7 +595,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -623,7 +611,6 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -654,7 +641,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -674,7 +660,6 @@ describe("AccountPage states", () => {
it("renders bond cards in expanded layout with bond-specific classes", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -693,7 +678,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -707,7 +691,6 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
@@ -726,7 +709,6 @@ describe("AccountPage states", () => {
},
],
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -738,17 +720,14 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account={
{
id: "account-1",
name: "Основной",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */
}
account={{
id: "account-1",
name: "Основной",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}}
/>,
);
expect(
@@ -777,33 +756,30 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentOperations={
{
items: [
{
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка акции",
figi: "FIGI-1",
id: "operation-1",
instrumentType: "share",
instrumentUid: "UID-1",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: { currency: "RUB", value: "-12" },
price: null,
quantity: null,
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
},
],
state: "loaded",
to: "/accounts/account-1/operations",
}
/* eslint-enable unicorn/no-null */
}
recentOperations={{
items: [
{
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка акции",
figi: "FIGI-1",
id: "operation-1",
instrumentType: "share",
instrumentUid: "UID-1",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: { currency: "RUB", value: "-12" },
price: null,
quantity: null,
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
},
],
state: "loaded",
to: "/accounts/account-1/operations",
}}
/>,
);
@@ -812,7 +788,6 @@ describe("AccountPage states", () => {
});
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const event: BrokerageEvent
= {
date: "2026-09-10",
@@ -827,41 +802,37 @@ describe("AccountPage states", () => {
type: "coupon",
};
/* eslint-enable unicorn/no-null */
renderPage(
<AccountPageLoaded
{...loadedProps()}
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentEvents={
{
items: [
event,
{
...event,
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
type: "dividend",
},
{
...event,
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
type: "maturity",
},
{
...event,
date: "2026-09-13",
projectedAmount: null,
type: "offer",
},
],
state: "loaded",
to: "/accounts/account-1/events",
}
/* eslint-enable unicorn/no-null */
}
recentEvents={{
items: [
event,
{
...event,
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
type: "dividend",
},
{
...event,
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
type: "maturity",
},
{
...event,
date: "2026-09-13",
projectedAmount: null,
type: "offer",
},
],
state: "loaded",
to: "/accounts/account-1/events",
}}
/>,
);
@@ -953,7 +924,6 @@ describe("AccountPage states", () => {
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const initialPortfolio: Portfolio
= {
...portfolio,
@@ -970,7 +940,6 @@ describe("AccountPage states", () => {
},
};
/* eslint-enable unicorn/no-null */
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
@@ -985,7 +954,7 @@ describe("AccountPage states", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
@@ -999,7 +968,6 @@ describe("AccountPage states", () => {
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
@@ -18,8 +18,6 @@ import {
useAccountPageViewModel,
} from "./AccountPage.vm";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "1" }],
@@ -66,7 +64,6 @@ const portfolio: Portfolio
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "150" }],
};
/* eslint-enable unicorn/no-null */
function configureTestAuth() {
configureAuthClient({
@@ -101,7 +98,7 @@ function mockLoadedAccountPage() {
),
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",
@@ -127,7 +124,6 @@ function mockLoadedAccountPage() {
nextCursor: null,
observedAt: "2026-08-13T10:00:00Z",
},
/* eslint-enable unicorn/no-null */
),
),
),
@@ -138,7 +134,7 @@ function mockLoadedAccountPage() {
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from(
{ length: 6 },
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
(_, index) =>
({
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
@@ -150,7 +146,6 @@ function mockLoadedAccountPage() {
ticker: `BND${index}`,
type: "coupon",
}),
/* eslint-enable unicorn/no-null */
),
}),
),
@@ -181,7 +176,7 @@ describe("useAccountPageViewModel", () => {
type: "CURRENCY",
},
...otherTypes.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
type =>
({
...portfolio.positions[2],
@@ -190,7 +185,6 @@ describe("useAccountPageViewModel", () => {
figi: `FIGI-${type}-NEXT`,
type,
}),
/* eslint-enable unicorn/no-null */
),
],
structure: { currencies: [], unvaluedCategories: [] },
@@ -248,7 +242,7 @@ describe("useAccountPageViewModel", () => {
shareText: "66,7 %",
},
],
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
kind: "bonds",
@@ -262,15 +256,15 @@ describe("useAccountPageViewModel", () => {
},
],
},
/* eslint-enable unicorn/no-null */ {
{
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
returnText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
logoSrc: null,
returnText: null,
returnTone: undefined,
shareText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
shareText: null,
},
],
},
@@ -351,10 +345,10 @@ it("keeps overview sections independent when their source data is incomplete", a
),
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(
{ hasNext: false, items: [], nextCursor: null },
/* eslint-enable unicorn/no-null */ { availability: "partial" },
{ availability: "partial" },
),
),
),
@@ -385,8 +379,6 @@ it("keeps overview sections independent when their source data is incomplete", a
it("refreshes every overview section through one account descriptor and observational reads", async () => {
configureTestAuth();
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const sections: Record<string, unknown>
= {
"events": { events: [], incomeTotalsByCurrency: [] },
@@ -395,7 +387,6 @@ it("refreshes every overview section through one account descriptor and observat
portfolio,
};
/* eslint-enable unicorn/no-null */
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
@@ -427,7 +418,7 @@ it("refreshes every overview section through one account descriptor and observat
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
@@ -441,7 +432,6 @@ it("refreshes every overview section through one account descriptor and observat
scope: null,
state: "succeeded",
},
/* eslint-enable unicorn/no-null */
),
),
);

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