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
@@ -33,6 +33,7 @@ function configureTestAuth() {
function renderPage(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -50,6 +51,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
@@ -102,9 +104,11 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
@@ -131,6 +135,7 @@ describe("AccountsPage", () => {
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -142,11 +147,9 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
return requestCount === 2
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
@@ -161,6 +164,7 @@ describe("AccountsPage", () => {
const warning = await screen.findByText(
"Не удалось обновить данные. Показаны последние полученные.",
);
expect(warning).toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
@@ -176,12 +180,10 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
return requestCount === 1
? HttpResponse.json(resource([]))
: HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
@@ -210,11 +212,13 @@ it("keeps unknown membership loading instead of declaring no accounts", async ()
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject((/* 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. */
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject(
{
data: null,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
expect(
screen.queryByText("Нет инвестиционных счетов"),
@@ -20,6 +20,7 @@ const AccountsPage: FC = () => {
sourceError={viewModel.sourceError}
/>
);
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
@@ -8,6 +8,7 @@ import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
if (
!accounts.data
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
@@ -21,6 +22,7 @@ function useAccountsPageViewModel() {
state: "error" as const,
};
}
if (!accounts.data?.length)
return {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
@@ -30,6 +32,7 @@ function useAccountsPageViewModel() {
sourceError: sync.sourceError,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
@@ -11,9 +11,11 @@ import {
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState {...props} />
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
@@ -12,11 +12,13 @@ type AccountsPageErrorProps = { code?: string; retry?: () => void };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected))
return (
<StateView
@@ -41,6 +43,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
action={(
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
)}
@@ -49,6 +52,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
@@ -21,15 +21,19 @@ type AccountsPageLoadedProps = AccountsRefreshStateProps & {
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack gap="lg">
<Stack gap={4}>
<Title order={1}>{t("accounts.title")}</Title>
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
@@ -38,6 +42,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
>
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
@@ -6,26 +6,35 @@ import { useTranslation } from "react-i18next";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
<Skeleton height={20} width="45%" />
<SimpleGrid aria-hidden="true" cols={1} spacing="lg">
{[0, 1].map(index => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
<Stack gap="sm">
<Skeleton height={24} width="35%" />
<Skeleton height={28} width="65%" />
</Stack>
<Stack gap="xs">
<Skeleton height={16} width="30%" />
<Skeleton height={40} width="60%" />
</Stack>
<Group grow>
<Skeleton height={56} />
<Skeleton height={56} />
</Group>
<Skeleton height={20} />
</Stack>
</Card>
@@ -19,13 +19,14 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
return (
<>
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Button
disabled={refreshing}
onClick={retry}
@@ -34,15 +35,18 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{!!(refreshing) && ((
{!!refreshing && (
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
))}
{!!(refreshError) && ((
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
@@ -54,7 +58,7 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
))}
)}
</>
);
};