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
@@ -31,12 +31,15 @@ import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
@@ -55,10 +58,12 @@ describe("Upcoming event date filters", () => {
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -68,6 +73,7 @@ describe("Upcoming event date filters", () => {
</QueryClientProvider>
),
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
@@ -91,20 +97,25 @@ describe("Upcoming event date filters", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -113,6 +124,7 @@ describe("Upcoming event date filters", () => {
);
const period = Object.fromEntries(new URLSearchParams(search));
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
@@ -125,6 +137,7 @@ describe("Upcoming event date filters", () => {
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
@@ -153,6 +166,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
@@ -171,6 +185,7 @@ describe("Upcoming event date filters", () => {
initialIndex={0}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -182,6 +197,7 @@ describe("Upcoming event date filters", () => {
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
@@ -192,6 +208,7 @@ describe("Upcoming event date filters", () => {
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
@@ -227,6 +244,7 @@ describe("Upcoming event date filters", () => {
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -258,6 +276,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(
resource({
accountId,
@@ -278,6 +297,7 @@ describe("Upcoming event date filters", () => {
]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -333,6 +353,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const events = [
{
date: "2026-10-01",
@@ -341,16 +362,19 @@ it("retains the previous complete forecast and timestamp when its source refresh
type: "coupon",
},
];
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
);
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -358,6 +382,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
</QueryClientProvider>
),
});
await waitFor(() =>
expect(result.current).toMatchObject({
events,
@@ -37,7 +37,8 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
query.mockReturnValue((/* 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. */
query.mockReturnValue(
{
data: {
accountId: "account",
@@ -51,8 +52,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
});
it("passes the selected route account to the query", () => {
@@ -72,7 +73,8 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue((/* 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. */
query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -82,8 +84,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
@@ -93,7 +95,8 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue((/* 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. */
query.mockReturnValue(
{
data: {
accountId: "account",
@@ -132,8 +135,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
@@ -180,7 +183,8 @@ describe("AccountEventsPage", () => {
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue((/* 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. */
query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -190,8 +194,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
@@ -10,7 +10,14 @@ type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error" ? <AccountEventsPageError {...viewModel} /> : <AccountEventsPageLoaded {...viewModel} />;
return viewModel.state === "error"
? (
<AccountEventsPageError {...viewModel} />
)
: (
<AccountEventsPageLoaded {...viewModel} />
);
};
export { AccountEventsPage };
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
@@ -38,8 +38,15 @@ function useAccountEventsPageViewModel(
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedFrom = isValidUrlPeriod
? urlFrom
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const appliedTo = isValidUrlPeriod
? urlTo
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
useEffect(() => {
@@ -49,19 +56,23 @@ function useAccountEventsPageViewModel(
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
]);
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
setPreviousPeriod([appliedFrom, appliedTo]);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
}
const events = useBrokerageEvents(accountId, {
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
{
accountId,
@@ -71,12 +82,15 @@ function useAccountEventsPageViewModel(
},
[events],
);
if (events.isLoading) return { state: "loading" };
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParameters({});
};
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
onReset,
@@ -86,6 +100,7 @@ function useAccountEventsPageViewModel(
state: "error",
};
if (!events.data) return { state: "loading" };
return {
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
@@ -115,14 +130,4 @@ function useAccountEventsPageViewModel(
};
}
const calendarDate = z.iso.date();
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
);
@@ -8,11 +8,13 @@ type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -10,7 +10,6 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
@@ -18,82 +17,94 @@ import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
function renderEvents() {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props,
) => {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props);
return (
<Stack gap="lg">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!(props.partial) && ((
{!!props.partial && (
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!(props.refreshing) && ((
)}
{!!props.refreshing && (
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
)}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
))}
)}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
onChange={onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
@@ -103,15 +114,18 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
valueFormat="DD.MM.YYYY"
/>
</Group>
<Group>
<Button disabled={!props.canApply} onClick={props.onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{renderEvents()}
<AccountEventsPageLoadedEvents {...props} />
</Stack>
);
};
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) {
const { t } = useTranslation();
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
};
return { onPeriodChange, t };
}
export { useAccountEventsPageLoadedVm };
@@ -8,18 +8,25 @@ const skeletonRowIds = ["first", "second", "third", "fourth"];
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
<Group>
<Skeleton height={60} width={220} />
<Skeleton height={60} width={220} />
</Group>
<Group>
<Skeleton height={36} width={116} />
<Skeleton height={36} width={108} />
</Group>
{skeletonRowIds.map(id => (
<Skeleton height={82} key={id} radius="md" />
))}