feat: show persisted investment data and sync state
Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes.
This commit is contained in:
+87
-23
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/entities/broker-operation";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||||
|
||||
@@ -39,11 +40,13 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
function renderPage(search = "") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/operations${search}`]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
@@ -193,13 +196,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||||
hasNext: !url.searchParams.get("cursor"),
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||||
hasNext: !url.searchParams.get("cursor"),
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{
|
||||
accountId: "account-1",
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
},
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
|
||||
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
|
||||
configureTestAuth();
|
||||
const searches: URLSearchParams[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
searches.push(params);
|
||||
if (params.has("cursor"))
|
||||
return HttpResponse.json(
|
||||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
items: [operation],
|
||||
hasNext: true,
|
||||
nextCursor: "old-cursor",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(searches.length).toBe(3));
|
||||
expect(searches[2].get("cursor")).toBeNull();
|
||||
for (const key of ["from", "to", "categories"])
|
||||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
type AccountOperationsPageViewModel =
|
||||
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
|
||||
| { state: "error"; retry: () => void }
|
||||
| {
|
||||
state: "loaded";
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
historyLoading?: boolean;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
|
||||
...firstPageQuery,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{ screen: "operations", accountId, ...firstPageQuery },
|
||||
[operations],
|
||||
);
|
||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||
limit: 100,
|
||||
}).queryKey;
|
||||
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
|
||||
exact: true,
|
||||
});
|
||||
resetCursors();
|
||||
} else void operations.refetch();
|
||||
} else void sync.refresh();
|
||||
};
|
||||
|
||||
if (operations.isLoading) return { state: "loading" };
|
||||
if (
|
||||
operations.isError &&
|
||||
(operations.isError || operations.meta?.sync.state === "failed") &&
|
||||
(!operations.data ||
|
||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||
)
|
||||
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
|
||||
|
||||
return {
|
||||
state: "loaded",
|
||||
refreshing: operations.isFetching,
|
||||
refreshError: operations.isError,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
partial: operations.meta?.availability !== "complete",
|
||||
historyLoading:
|
||||
operations.meta?.coverage?.historyState === "loading" ||
|
||||
operations.meta?.coverage?.historyState === "partial",
|
||||
refreshing: operations.isFetching || sync.refreshing,
|
||||
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||
retry,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
|
||||
+24
-4
@@ -5,6 +5,7 @@ import {
|
||||
MultiSelect,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type OperationCategory,
|
||||
operationCategories,
|
||||
} from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
type AccountOperationsPageLoadedProps = Extract<
|
||||
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Title order={1}>{t("ledger.title")}</Title>
|
||||
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
{props.historyLoading ? <Alert>{t("investment.history")}</Alert> : null}
|
||||
{props.refreshing ? (
|
||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||
) : null}
|
||||
{props.refreshError ? (
|
||||
<Alert color="yellow">
|
||||
{t("accounts.refreshError")}
|
||||
<Alert color="yellow" role="status" aria-live="polite">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
<Button variant="subtle" onClick={props.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
operation={operation}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
) : !props.partial ? (
|
||||
<StateView
|
||||
title={t("ledger.emptyTitle")}
|
||||
text={t("ledger.emptyText")}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{hasNext || hasPrevious ? (
|
||||
<Group justify="space-between">
|
||||
<Button disabled={!hasPrevious} onClick={onPrevious}>
|
||||
|
||||
Reference in New Issue
Block a user