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
@@ -8,19 +8,24 @@ import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = {
accountId: string;
section?: "events" | "income" | "operations";
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 },
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useAccountScopeSwitchVm } from "./AccountScopeSwitch.vm";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const { accountId, data, onChange, t } = useAccountScopeSwitchVm(props);
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={onChange}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch };
@@ -1,6 +1,3 @@
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router";
@@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const { search } = useLocation();
const data = [
{ label: t("accounts.all"), value: "all" },
...(accounts ?? []).map(account => ({
@@ -21,27 +19,23 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
value: account.id,
})),
];
if (data.every(item => item.value !== accountId))
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
return (
<Select
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
search: filters.toString(),
});
}}
value={accountId}
w="100%"
/>
);
};
export { AccountScopeSwitch };
const onChange: (value: null | string) => void = (value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section]
.filter(Boolean)
.join("/"),
search: filters.toString(),
});
};
return { accountId, data, onChange, t };
}
export { useAccountScopeSwitchVm };
@@ -16,6 +16,7 @@ function renderBreadcrumbs(
accounts?: { id: string; name: string }[],
) {
useBrokerageAccounts.mockReturnValue({ data: accounts });
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
@@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => {
"page",
);
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
for (const separator of screen.getAllByText("→")) {
expect(separator).toHaveAttribute("aria-hidden", "true");
}
+13 -2
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs";
@@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
const operations = useMatch({
end: true,
path: "/accounts/:accountId/operations",
});
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" });
@@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => {
accountId={operations.params.accountId ?? ""}
section="operations"
/>
<AccountScopeSwitch
accountId={operations.params.accountId ?? ""}
section="operations"
@@ -50,6 +53,7 @@ const AppBreadcrumbs: FC = () => {
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
@@ -63,6 +67,7 @@ const AppBreadcrumbs: FC = () => {
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
@@ -73,6 +78,7 @@ const AppBreadcrumbs: FC = () => {
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
@@ -80,7 +86,12 @@ const AppBreadcrumbs: FC = () => {
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
return settings
? (
<BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />
)
: undefined;
};
export { AppBreadcrumbs };
@@ -7,9 +7,11 @@ 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={[
@@ -7,11 +7,13 @@ import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css";
type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation();
return (
<Breadcrumbs
aria-label={t("breadcrumbs.label")}
@@ -7,9 +7,11 @@ 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={[
@@ -7,9 +7,11 @@ 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={[
-178
View File
@@ -30,31 +30,6 @@
}
}
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.stocksIcon {
color: var(--mantine-color-green-8);
}
@@ -67,163 +42,10 @@
color: var(--mantine-color-gray-7);
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.navLink {
transition: background-color 180ms ease-in-out;
}
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
@media (max-width: 47.99375em) {
+33 -80
View File
@@ -1,11 +1,16 @@
import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import {
BondsIcon,
CardsIcon,
PortfolioIcon,
SettingsIcon,
StocksIcon,
} from "@wealthledger/icons";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
@@ -16,80 +21,34 @@ const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
const { pathname } = useLocation();
const items = [
{
icon: (
<>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</>
),
icon: <PortfolioIcon size={20} />,
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
icon: (
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</>
),
icon: <CardsIcon size={20} />,
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
icon: (
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
icon: <StocksIcon size={20} />,
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
icon: (
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</>
),
icon: <BondsIcon size={20} />,
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
icon: (
<>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
icon: <SettingsIcon size={20} />,
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
@@ -105,6 +64,7 @@ const AppShell: FC<AppShellProps> = (props) => {
<MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="sm">
{items.map(item => (
<NavLink
@@ -117,45 +77,38 @@ const AppShell: FC<AppShellProps> = (props) => {
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={20}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={20}
>
{item.icon}
</svg>
{item.icon}
</span>
)}
to={item.to}
/>
))}
</MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}>
<div className={classes.content}>
{contentAboveOutlet}
<Outlet />
</div>
</MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map(item => (
<RouterNavLink
className={
(pathname.startsWith(item.to)) ? (classes.active) : undefined
}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
))}
{items.map((item) => {
const appearanceClass = pathname.startsWith(item.to)
? classes.active
: undefined;
return (
<RouterNavLink
className={appearanceClass}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
</nav>
</MantineAppShell>
);