refactor: address frontend review feedback
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s

This commit was merged in pull request #43.
This commit is contained in:
2026-09-28 19:20:32 +03:00
parent ca0ad4f824
commit a4046dbe87
139 changed files with 1549 additions and 941 deletions
@@ -19,8 +19,6 @@ import {
useBankSummary,
} from "./queries";
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card: BankCard
= {
archivedAt: null,
@@ -35,7 +33,6 @@ const card: BankCard
version: 1,
};
/* eslint-enable unicorn/no-null */
const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a",
+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: "Жильё" },
{ 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,
].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 {
@@ -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", {