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,112 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json({
accountId,
calculatedAt: "2099-01-01T00:00:00Z",
events: [],
incomeTotalsByCurrency: [],
});
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2099 – 28.02.2099");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
fireEvent.click(from);
fireEvent.click(
await screen.findByRole("button", { name: /10 February 2099/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }),
);
expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
);
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"10.02.2099 – 28.02.2099",
);
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.02.2099",
),
);
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01&to=2099-02-28",
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe(""));
expect(await screen.findByLabelText("Период")).toHaveTextContent("");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(
screen.getByText("За выбранный период событий не найдено"),
).toBeVisible();
},
);
});
@@ -0,0 +1,165 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(),
}));
const query = vi.mocked(useBrokerageEvents);
function renderPage() {
return render(
<MantineProvider>
<MemoryRouter>
<AccountEventsPage />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AccountEventsPage", () => {
beforeEach(() => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
});
it("passes the selected route account to the query", () => {
render(
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/selected-account/events"]}>
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>,
);
expect(query).toHaveBeenCalledWith("selected-account", {});
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined, {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
logoName: "ofz.png",
isin: "RU000A0JX0J2",
},
{
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: null,
ticker: "OFZ",
type: "offer",
logoName: null,
isin: null,
},
],
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
const { container } = renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("20.08.2026 · RU000A0JX0J2")).toBeVisible();
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
expect(logo).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
);
expect(screen.getAllByText("O")).toHaveLength(1);
if (!logo) throw new Error("Expected the event logo");
fireEvent.error(logo);
expect(container.querySelector("img")).toBeNull();
expect(screen.getAllByText("O")).toHaveLength(2);
});
it("renders empty and retryable error states", () => {
const refetch = vi.fn();
const { rerender } = renderPage();
expect(
screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument();
query.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
refetch,
} as never);
rerender(
<MantineProvider>
<MemoryRouter>
<AccountEventsPage />
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(refetch).toHaveBeenCalledOnce();
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue({
data: undefined,
isError: false,
isLoading: true,
refetch: vi.fn(),
} as never);
renderPage();
const status = screen.getByRole("status", { name: "Загрузка" });
expect(status).toHaveAttribute("aria-busy", "true");
expect(status.querySelector("button")).toBeNull();
});
});
@@ -0,0 +1,17 @@
import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
refreshing: boolean;
refreshError: boolean;
retry: () => void;
draftFrom: string | null;
draftTo: string | null;
canApply: boolean;
onDraftFromChange: (value: string | null) => void;
onDraftToChange: (value: string | null) => void;
onApply: () => void;
onReset: () => void;
};
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
}, [appliedFrom, appliedTo]);
const events = useBrokerageEvents(accountId, {
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
if (events.isLoading) return { state: "loading" };
if (events.isError && !events.data)
return {
state: "error",
retry: () => {
void events.refetch();
},
};
if (!events.data) return { state: "loading" };
return {
state: "loaded",
events: events.data.events,
refreshing: events.isFetching,
refreshError: events.isError,
retry: () => {
void events.refetch();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
},
};
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -0,0 +1,12 @@
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
);
};
export { AccountEventsPageEmpty };
@@ -0,0 +1,20 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
/>
);
};
export { AccountEventsPageError };
@@ -0,0 +1,90 @@
import {
Alert,
Button,
Group,
Progress,
Stack,
Text,
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
<Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
value={[props.draftFrom, props.draftTo]}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
valueFormat="DD.MM.YYYY"
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</Group>
<Group>
<Button onClick={props.onApply} disabled={!props.canApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
{props.events.length ? (
<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>
) : (
<AccountEventsPageEmpty />
)}
</Stack>
);
};
export { AccountEventsPageLoaded };
@@ -0,0 +1,29 @@
import { Group, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
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 key={id} height={82} radius="md" />
))}
</LoadingSkeleton>
);
};
export { AccountEventsPageLoading };