refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s

This commit is contained in:
2026-09-23 08:34:58 +03:00
parent 85fcfe3d07
commit b987fd8e78
190 changed files with 131 additions and 108 deletions
@@ -0,0 +1,30 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = {
accountId: string;
section?: "events" | "income" | "operations";
};
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName =
accountId === "all"
? t("accounts.all")
: (accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`;
const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
section ? { label: accountName, to: accountPath } : { label: accountName },
...(section ? [{ label: t(`breadcrumbs.${section}`) }] : []),
];
return <BreadcrumbTrail items={items} />;
};
export { AccountBreadcrumbs };
@@ -0,0 +1,45 @@
import { Select } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const { search } = useLocation();
const data = [
{ value: "all", label: t("accounts.all") },
...(accounts ?? []).map((account) => ({
value: account.id,
label: account.name,
})),
];
if (!data.some((item) => item.value === accountId))
data.push({ value: accountId, label: t("breadcrumbs.accountFallback") });
return (
<Select
label={t("accounts.scope")}
value={accountId}
data={data}
allowDeselect={false}
maw={360}
w="100%"
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
navigate({
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
search: filters.toString(),
});
}}
/>
);
};
export { AccountScopeSwitch };
@@ -0,0 +1,13 @@
.trail {
margin-bottom: var(--mantine-spacing-md);
}
.link {
color: var(--mantine-primary-color-filled);
}
@media (max-width: 47.99375em) {
.trail[data-depth="3"] > :nth-child(-n + 2) {
display: none;
}
}
@@ -0,0 +1,93 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
const { useBrokerageAccounts } = vi.hoisted(() => ({
useBrokerageAccounts: vi.fn(),
}));
vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts }));
function renderBreadcrumbs(
path: string,
accounts?: { id: string; name: string }[],
) {
useBrokerageAccounts.mockReturnValue({ data: accounts });
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<AppBreadcrumbs />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AppBreadcrumbs", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("shows the current accounts page without a link", () => {
renderBreadcrumbs("/accounts");
expect(
screen.getByRole("navigation", { name: "Хлебные крошки" }),
).toBeInTheDocument();
expect(screen.getByText("Брокерские счета")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("shows settings as the current page without a link", () => {
renderBreadcrumbs("/settings");
expect(screen.getByText("Настройки")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("links to the account hierarchy from operations", () => {
renderBreadcrumbs("/accounts/account-1/operations", [
{ id: "account-1", name: "ИИС" },
]);
expect(
screen.getByRole("link", { name: "Брокерские счета" }),
).toHaveAttribute("href", "/accounts");
expect(screen.getByRole("link", { name: "ИИС" })).toHaveAttribute(
"href",
"/accounts/account-1",
);
expect(screen.getByText("Операции")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
for (const separator of screen.getAllByText("→")) {
expect(separator).toHaveAttribute("aria-hidden", "true");
}
});
it("uses the account fallback without exposing the identifier", () => {
renderBreadcrumbs("/accounts/account-1");
expect(
within(screen.getByRole("navigation")).getByText("Счет"),
).toHaveAttribute("aria-current", "page");
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
});
it.each(["/login", "/unknown"])("does not render on %s", (path) => {
renderBreadcrumbs(path);
expect(
screen.queryByRole("navigation", { name: "Хлебные крошки" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,86 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
const operations = useMatch({
path: "/accounts/:accountId/operations",
end: true,
});
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
const account = useMatch({ path: "/accounts/:accountId", end: true });
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const accounts = useMatch({ path: "/accounts", end: true });
const settings = useMatch({ path: "/settings", end: true });
const stock = useMatch({ path: "/stocks/:stockId", end: true });
const bond = useMatch({ path: "/bonds/:bondId", end: true });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (operations)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={operations.params.accountId ?? ""}
section="operations"
/>
<AccountScopeSwitch
accountId={operations.params.accountId ?? ""}
section="operations"
/>
</Stack>
);
if (events)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
/>
</Stack>
);
if (income)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
/>
</Stack>
);
if (account)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
return null;
};
export { AppBreadcrumbs };
@@ -0,0 +1,20 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -0,0 +1,36 @@
import { Breadcrumbs } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css";
type Crumb = { label: string; to?: string };
type BreadcrumbTrailProps = { items: Crumb[] };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation();
return (
<Breadcrumbs
aria-label={t("breadcrumbs.label")}
className={classes.trail}
component="nav"
data-depth={items.length}
separator={<span aria-hidden="true">→</span>}
>
{items.map((item) =>
item.to ? (
<Link className={classes.link} key={item.to} to={item.to}>
{item.label}
</Link>
) : (
<span aria-current="page" key={item.label}>
{item.label}
</span>
),
)}
</Breadcrumbs>
);
};
export { BreadcrumbTrail };
@@ -0,0 +1,20 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -0,0 +1,20 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };