refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -64,8 +64,10 @@ export const Available: Story = {
},
};
export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Unavailable: Story
= {
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, openedOn: null, portfolioSummary: null },
@@ -76,12 +78,12 @@ export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture
docs: {
description: {
story:
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const Empty: Story = {
args: {
@@ -191,8 +193,10 @@ export const LargeValues: Story = {
name: "Длинные суммы",
};
export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const IndependentFreshness: Story
= {
args: {
accounts: brokerageAccountCards(
[
@@ -231,5 +235,5 @@ export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null --
),
},
name: "Независимая свежесть и ошибка сводок",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
@@ -29,29 +29,26 @@ describe("BrokerageAccountList", () => {
],
},
type: "BROKERAGE",
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
id: "account-2",
name: "Недоступный",
openedOn: null,
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.
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 2,
totalsByCurrency: [],
},
type: "BROKERAGE",
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
id: "account-2",
name: "Недоступный",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
id: "account-3",
name: "Пустой",
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 2,
totalsByCurrency: [],
},
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */),
])}
/>
</MemoryRouter>
@@ -71,19 +68,11 @@ describe("BrokerageAccountList", () => {
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent(
"5",
);
expect(
available.getAllByRole("definition")[1],
).toHaveTextContent("12");
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent(
"2",
);
expect(
unvalued.getAllByRole("definition")[1],
).toHaveTextContent("0");
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
@@ -94,19 +83,22 @@ describe("BrokerageAccountList", () => {
it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards(
["fresh", "stale"].map(id => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
id,
name: id,
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
type: "BROKERAGE" as const,
}
/* eslint-enable unicorn/no-null */))),
["fresh", "stale"].map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
id =>
({
id,
name: id,
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
type: "BROKERAGE" as const,
}),
/* eslint-enable unicorn/no-null */
),
[
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
.meta,
@@ -114,6 +106,7 @@ it("shows portfolio summary freshness independently for each account", () => {
.meta,
],
);
render(
<MantineProvider>
<MemoryRouter>
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -40,13 +41,14 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
{formatMoney(total)}
</Text>
))}
{!!(summary.remainingCurrencyCount) && ((
{!!summary.remainingCurrencyCount && (
<Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount,
})}
</Text>
))}
)}
</Stack>
);
};
@@ -72,76 +74,78 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))}
</Text>
<svg
aria-hidden="true"
className={classes.accountIcon}
fill="none"
focusable="false"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
<WalletIcon className={classes.accountIcon} />
</Group>
<Text className={classes.name} component="h2" fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
{!!(source?.lastSuccessfulAt) && ((
{!!source?.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
))}
)}
{(source?.sync.state === "running"
|| source?.sync.state === "queued") && ((
|| source?.sync.state === "queued") && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
{(source?.sync.state === "failed") && ((
)}
{source?.sync.state === "failed" && (
<Text size="xs">{t("accounts.refreshError")}</Text>
))}
{(source?.freshness === "stale") && ((
)}
{source?.freshness === "stale" && (
<Text size="xs">{t("investment.stale")}</Text>
))}
)}
</Stack>
{!!(positionCounts) && ((
{!!positionCounts && (
<dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
</dd>
</div>
<div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
))}
)}
<Group className={classes.footer} gap="sm" justify="space-between">
{!!(account.openedOn) && ((
{!!account.openedOn && (
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
))}
)}
<Text className={classes.open} component="span" fw={600} size="sm">
{t("accounts.openAccount")}
<span aria-hidden="true" className={classes.arrow}>
→
</span>