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,257 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { TbankConnectionSection } from "./TbankConnectionSection";
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts",
).queryKey;
const portfolioQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
const operationsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
const stockQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/stocks/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const bondQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bonds/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const bankCardsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards",
{
params: { query: { includeArchived: "false" } },
},
).queryKey;
const unrelatedQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/auth/me",
).queryKey;
function renderSection(client: QueryClient) {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
});
return render(
<MantineProvider>
<QueryClientProvider client={client}>
<TbankConnectionSection />
</QueryClientProvider>
</MantineProvider>,
);
}
function seededClient() {
const client = createTestQueryClient();
client.setQueryData(brokerageAccountsQueryKey, [
{
id: "account-1",
name: "Основной счет",
type: "BROKERAGE",
openedOn: null,
},
]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" });
client.setQueryData(bondQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" });
client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]);
return client;
}
describe("TbankConnectionSection", () => {
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
renderSection(client);
await screen.findByText("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument(),
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
it("creates the same financial boundary when an existing connection is replaced", async () => {
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-13T00:00:00Z",
}),
),
);
const client = seededClient();
renderSection(client);
await screen.findByRole("button", { name: "Заменить token" });
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Заменить token" }));
await waitFor(() =>
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(),
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
});
it("commits disconnected status and resets financial queries after deleting", async () => {
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
}),
),
);
const client = seededClient();
renderSection(client);
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Подключить" }),
).toBeInTheDocument(),
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
it("cancels an in-flight previous-boundary query and ignores its late response", async () => {
const client = seededClient();
let finishPrevious: ((value: { source: string }) => void) | undefined;
let aborted = false;
const previousResponse = new Promise<{ source: string }>((resolve) => {
finishPrevious = resolve;
});
const previousRequest = client
.fetchQuery({
queryKey: operationsQueryKey,
queryFn: ({ signal }) => {
signal.addEventListener("abort", () => {
aborted = true;
});
return previousResponse;
},
})
.catch(() => undefined);
renderSection(client);
await screen.findByText("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await waitFor(() => expect(aborted).toBe(true));
await previousRequest;
finishPrevious?.({ source: "previous-connection" });
await Promise.resolve();
expect(client.getQueryData(operationsQueryKey)).toBeUndefined();
});
it("preserves status and financial cache when a connection update fails", async () => {
server.use(
http.put("*/api/v1/tbank-connection", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
const client = seededClient();
renderSection(client);
await screen.findByText("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await screen.findByText(
"T-Bank временно недоступен. Повторите попытку позже.",
);
expect(
screen.getByRole("button", { name: "Подключить" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined();
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
});
it("preserves connected status and financial cache when deletion fails", async () => {
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
}),
),
http.delete("*/api/v1/tbank-connection", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
const client = seededClient();
renderSection(client);
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз.");
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined();
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
});
});
@@ -0,0 +1,17 @@
import type { FC } from "react";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel();
if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />;
};
export { TbankConnectionSection };
@@ -0,0 +1,29 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
describe("TbankConnectionSection state views", () => {
it("renders loading and retries a status error", () => {
const retry = vi.fn();
const { rerender } = render(
<MantineProvider>
<TbankConnectionLoading />
</MantineProvider>,
);
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
rerender(
<MantineProvider>
<TbankConnectionError retry={retry} />
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,34 @@
import { Alert, Button, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type TbankConnectionErrorProps = {
retry: () => void;
};
const TbankConnectionLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={20} width="45%" />
<Skeleton height={36} width={140} radius="sm" />
</LoadingSkeleton>
);
};
const TbankConnectionError: FC<TbankConnectionErrorProps> = (props) => {
const { retry } = props;
const { t } = useTranslation();
return (
<Alert color="red" title={t("tbank.statusError")}>
<Button variant="subtle" onClick={retry}>
{t("tbank.retry")}
</Button>
</Alert>
);
};
export { TbankConnectionError, TbankConnectionLoading };
@@ -0,0 +1,14 @@
import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(),
});
if (status.isPending) return { state: "loading" as const };
if (status.isError)
return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data };
}
export { useTbankConnectionViewModel };
@@ -0,0 +1,139 @@
import {
Alert,
Button,
Modal,
PasswordInput,
Stack,
Text,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { apiErrorCode, apiQueryClient } from "@/shared/api";
import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
} from "../../model/connection-boundary";
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
};
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
props,
) => {
const { status } = props;
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<{ token: string }>();
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
reset();
},
onError: (error) => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"),
});
const remove = apiQueryClient.useMutation(
"delete",
"/api/v1/tbank-connection",
{
onSuccess: () => {
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt: null,
});
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
},
);
const pending = update.isPending || remove.isPending;
const connected = status.connected;
return (
<Stack mt="xl">
<Text fw={600}>{t("tbank.title")}</Text>
<Text size="sm">
{connected
? t("tbank.connected", {
date: new Date(status.connectedAt ?? "").toLocaleDateString(
"ru-RU",
),
})
: t("tbank.disconnected")}
</Text>
<Text size="sm" c="dimmed">
{t("tbank.hint")}
</Text>
<form
onSubmit={handleSubmit(({ token }) => {
setRequestError(null);
update.mutate({ body: { token } });
})}
>
<fieldset disabled={pending}>
<Stack>
<PasswordInput
label={t("tbank.token")}
{...register("token", { required: t("tbank.tokenRequired") })}
error={errors.token?.message}
/>
{requestError && (
<Alert color="red">
{t(
requestError === "INVALID_TBANK_TOKEN"
? "tbank.invalidToken"
: requestError === "TBANK_UNAVAILABLE"
? "tbank.unavailable"
: "tbank.requestError",
)}
</Alert>
)}
<Button type="submit" loading={update.isPending}>
{t(connected ? "tbank.replace" : "tbank.connect")}
</Button>
{connected && (
<Button
variant="outline"
color="red"
onClick={() => setDeleteOpened(true)}
>
{t("tbank.delete")}
</Button>
)}
</Stack>
</fieldset>
</form>
<Modal
opened={deleteOpened}
onClose={() => !pending && setDeleteOpened(false)}
title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<Stack>
<Text>{t("tbank.deleteText")}</Text>
<Button
color="red"
loading={remove.isPending}
disabled={pending}
onClick={() => remove.mutate({})}
>
{t("tbank.deleteConfirm")}
</Button>
</Stack>
</Modal>
</Stack>
);
};
export { TbankConnectionSectionLoaded };