diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index b0ef764..d43def2 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -90,9 +90,13 @@ function configureTestAuth() { }); } -function renderPage(search = "", accountId = "account-1") { +function renderPage( + search = "", + accountId = "account-1", + queryClient = createTestQueryClient(), +) { return render( - + { it("marks cashless operations without rendering operation details", () => { render( @@ -545,20 +561,87 @@ describe("AccountOperationsPage", () => { ).toBeInTheDocument(); }); - it("renders an error state", async () => { - configureTestAuth(); - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Не удалось загрузить журнал", undefined, { - timeout: 4000, - }), - ).toBeInTheDocument(); - }); + it( + "renders an error state with retry that recovers in place", + { timeout: TEST_MS }, + async () => { + configureTestAuth(); + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(loadedOperations)); + }), + ); + renderPage(); + expect( + await screen.findByText("Не удалось загрузить журнал", undefined, { + timeout: RETRIES_EXHAUSTED_MS, + }), + ).toBeInTheDocument(); + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + + expect(await screen.findByText("Покупка акций")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached operations, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + configureTestAuth(); + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(loadedOperations)); + }), + ); + const queryClient = createTestQueryClient(); + renderPage("", "account-1", queryClient); + await screen.findByText("Покупка акций"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: [ + "get", + "/api/v1/brokerage-accounts/{accountId}/operations", + ], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Покупка акций")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { configureTestAuth(); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index a585843..0e53a4f 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -12,7 +12,12 @@ import { useBrokerageOperationHistory, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + readRetry, +} from "@/shared/api"; import { defaultPeriod, isValidPeriod, @@ -42,11 +47,16 @@ type AccountOperationsPageViewModel onPrevious: () => void; onReset: () => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } - | { onReset: () => void; state: "error" } + | { + onReset: () => void; + readRetry: ReturnType; + state: "error"; + } | { state: "loading" }; const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { @@ -107,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { isFetching: operations.isFetching, }); + const retry = readRetry([operations]); + if (availability === "loading" && operations.isLoading) return { state: "loading" }; @@ -134,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { (isInvalidCursor || !operations.data) && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) - return { onReset, state: "error" }; + return { onReset, readRetry: retry, state: "error" }; if (!operations.data) return availability === "unavailable" - ? { onReset, state: "error" } + ? { onReset, readRetry: retry, state: "error" } : { state: "loading" }; const nextCursor = operations.data.nextCursor; @@ -182,6 +194,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { onPrevious: history.onPrevious, onReset, partial: operations.meta?.completeness === "incomplete", + readRetry: retry, source: operations.meta, state: "loaded", totalVisible: operations.meta?.completeness === "complete", diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx index a45e4a0..01aa336 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -1,9 +1,11 @@ import type { FC } from "react"; -import { Button } from "@mantine/core"; +import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetryButton } from "@/shared/ui"; + import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageErrorProps = Extract< @@ -19,9 +21,18 @@ const AccountOperationsPageError: FC = ( return ( - {t("ledger.reset")} - + + {props.readRetry.exhausted && ( + + )} + + + )} text={t("ledger.errorText")} title={t("ledger.errorTitle")} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index afe7301..447205a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -19,7 +19,7 @@ import { OperationGroup, } from "@/entities/broker-operation"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC = ( stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}}