From d3335a7a8d2965e23f53f61f08d7a9cbf37dbaaa Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:36:56 +0300 Subject: [PATCH] feat(accounts): offer retry after exhausted accounts reads (WealthLedger/frontend#55) --- .../ui/AccountsPage/AccountsPage.test.tsx | 97 ++++++++++++++++++- .../accounts/ui/AccountsPage/AccountsPage.tsx | 1 + .../ui/AccountsPage/AccountsPage.vm.ts | 33 +++++-- .../components/AccountsPageError.view.tsx | 16 ++- .../components/AccountsPageLoaded.view.tsx | 5 +- 5 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 6da65ee..9fc2416 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -261,6 +261,101 @@ describe("AccountsPage", () => { }); }); +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + +describe("exhausted read retries", () => { + it( + "keeps the cached list, warns and retries the same accounts GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + if (isFailing) + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + + return HttpResponse.json(resource([account])); + }), + ); + const queryClient = createTestQueryClient(); + renderPage(queryClient); + await screen.findByText("Основной"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: brokerageAccountsQueryKey, + }); + + 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); + expect(screen.getByText("Основной")).toBeInTheDocument(); + }, + ); + + it( + "offers retry on the unavailable state after an initial failure", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + server.use( + http.get("*/api/v1/brokerage-accounts", () => + isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([account]))), + ); + renderPage(createTestQueryClient()); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Основной")).toBeInTheDocument(); + }, + ); + + it("does not offer a plain retry when the connection is lost", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })), + ); + renderPage(createTestQueryClient()); + + expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" })) + .toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }); +}); + it.each(["none", "failed"] as const)( "keeps incomplete empty membership distinct from no accounts (attempt=%s)", async (state) => { diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 8e02cdc..e141610 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -16,6 +16,7 @@ const AccountsPage: FC = () => { ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 4f3252c..e9c1fef 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,14 +4,26 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + readRetry, +} from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; +type AccountsPageReadRetry = ReturnType; + type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" } - | { code?: string; state: "error" } - | { state: "empty" } - | { state: "loading" }; + = | { + accounts: BrokerageAccountCard[]; + meta: NonNullable["meta"]>; + readRetry: AccountsPageReadRetry; + state: "loaded"; + } + | { code?: string; readRetry: AccountsPageReadRetry; state: "error" } + | { state: "empty" } + | { state: "loading" }; function isAccountsAccessFailure(code?: string) { return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) @@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { useScreenSync({ screen: "accounts" }, [accounts]); const code = apiErrorCode(accounts.error); + const retry = readRetry([accounts]); const isAccessFailure = isAccountsAccessFailure(code); const hasResult = !!accounts.data @@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { isFetching: accounts.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!hasResult || !accounts.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; if (accounts.data.length === 0) return { @@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { return { accounts: brokerageAccountCards(accounts.data, meta.dependencies), meta, + readRetry: retry, state: "loaded" as const, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx index 7ad5c98..9d4cf6e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx @@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; -type AccountsPageErrorProps = { code?: string }; +type AccountsPageErrorProps = { + code?: string; + readRetry?: ReadRetryProps; +}; const AccountsPageError: FC = (props) => { const { code } = props; @@ -38,8 +42,18 @@ const AccountsPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 96d5d88..ef5e51e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -11,7 +11,7 @@ import { BrokerageAccountList, } from "@/entities/brokerage-account"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; const DECIMAL_BASE = 10n; const DECIMAL_PART_COUNT = 2; @@ -20,6 +20,7 @@ const ZERO = 0n; type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; meta: InvestmentResourceMeta; + readRetry?: ReadRetryProps; }; type Money = { currency: string; value: string }; @@ -132,6 +133,8 @@ const AccountsPageLoaded: FC = (props) => { lastSuccessfulAt={props.meta.lastSuccessfulAt} stale={props.meta.freshness === "stale"} /> + + {!!props.readRetry && }