diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index c4642c2..eb44779 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; @@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => { loading: false, }); }); + + it("carries the retry of an exhausted read and only of that read", () => { + const refetch = vi.fn(() => Promise.resolve()); + + const state = section({ + data: { rows: [] }, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, + isError: true, + isFetching: false, + refetch, + }); + + expect(state.readRetry?.exhausted).toBe(true); + state.readRetry?.onRetry(); + expect(refetch).toHaveBeenCalledOnce(); + }); + + it("offers no retry for access loss or a healthy read", () => { + const base = { data: undefined, isFetching: false, refetch: vi.fn() }; + + expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true }) + .readRetry?.exhausted).toBe(false); + expect(section({ ...base, error: null, isError: false }) + .readRetry?.exhausted).toBe(false); + }); }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index a18b264..7c62f68 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,10 +1,11 @@ -import type { InvestmentResourceMeta } from "@/shared/api"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; type SectionState = { error: boolean; hasData: boolean; loading: boolean; partial?: boolean; + readRetry?: ReturnType; }; function hasSectionData(data: unknown) { @@ -13,8 +14,11 @@ function hasSectionData(data: unknown) { function section(query: { data: unknown; + error?: unknown; isError: boolean; + isFetching?: boolean; meta?: InvestmentResourceMeta; + refetch?: () => Promise; }): SectionState { const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); @@ -24,6 +28,17 @@ function section(query: { hasData, loading: !hasData && !isError, partial: query.meta?.completeness === "incomplete", + // Each section retries only its own GET, so sections recover independently. + readRetry: query.refetch + ? readRetry([ + { + error: query.error, + isError: query.isError, + isFetching: query.isFetching ?? false, + refetch: query.refetch, + }, + ]) + : undefined, }; } diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 5c37ba1..f9c32c6 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -12,6 +12,8 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetry } from "@/shared/ui"; + import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -81,6 +83,8 @@ const InstrumentSection: FC = (props) => { )} + {!!props.state.readRetry && } + {sectionContent} ); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx new file mode 100644 index 0000000..db2644f --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -0,0 +1,155 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; +import { server } from "@/test/server"; + +import { AccountEventsPage } from "./AccountEventsPage"; + +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + +const snapshot = resource({ + events: [ + { + date: "2026-10-01", + dateKind: "event", + displayName: "Synthetic coupon", + figi: null, + instrumentUid: null, + isin: null, + logoName: null, + projectedAmount: { currency: "RUB", value: "123.45" }, + ticker: "SYN", + type: "coupon", + }, + ], + incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], +}); + +function renderEvents() { + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + const queryClient = createTestQueryClient(); + + render( + + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); + + return { queryClient }; +} + +describe("AccountEventsPage read retry", () => { + it( + "offers retry after an initial failure and recovers in place", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(snapshot); + }), + ); + renderEvents(); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached events, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(snapshot); + }), + ); + const { queryClient } = renderEvents(); + await screen.findByText("Купон · Synthetic coupon"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); + + it("does not offer a plain retry when the account is gone", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })), + ); + renderEvents(); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Загрузка" })) + .not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 15994eb..7a3db60 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -169,15 +169,18 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText("O")).toHaveLength(2); }); - it("renders empty and quiet error states", async () => { + it("renders empty and error states with retry", async () => { const { rerender } = renderPage(); expect( screen.getByText("За выбранный период событий не найдено"), ).toBeInTheDocument(); query.mockReturnValue({ data: undefined, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, isError: true, + isFetching: false, isLoading: false, + refetch: vi.fn(), } as never); rerender( @@ -187,7 +190,7 @@ describe("AccountEventsPage", () => { , ); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible(); }); it("shows a structural busy state before events are available", () => { diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 92ef995..3190415 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,13 +1,12 @@ import { useState } from "react"; import { useParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { defaultPeriod, isValidPeriod, @@ -28,11 +27,16 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => 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" }; function useAccountEventsPageViewModel(): AccountEventsPageViewModel { @@ -83,6 +87,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { isFetching: events.isFetching, }); + const retry = readRetry([events]); + if (availability === "loading" && events.isLoading) return { state: "loading" }; @@ -94,11 +100,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) return { onReset, + readRetry: retry, state: "error", }; if (!events.data) return availability === "unavailable" - ? { onReset, state: "error" } + ? { onReset, readRetry: retry, state: "error" } : { state: "loading" }; return { @@ -122,6 +129,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onReset, partial: events.meta?.completeness === "incomplete", + readRetry: retry, source: events.meta, state: "loaded", totalVisible: events.meta?.completeness === "complete", diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index 86666b5..b36b3d2 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { onReset: () => void }; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; + +type AccountEventsPageErrorProps = { + onReset: () => void; + readRetry: ReadRetryProps; +}; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); @@ -13,6 +18,13 @@ const AccountEventsPageError: FC = (props) => { + {props.readRetry.exhausted && ( + + )} + diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 4fbd2f7..514d839 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -18,7 +18,7 @@ import { brokerageEventKey, } from "@/entities/brokerage-event"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -83,6 +83,8 @@ const AccountEventsPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index bcd6ce8..2e6def7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -38,6 +38,10 @@ import { } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + const analytics = { accountId: "account-1", @@ -433,21 +437,95 @@ describe("AccountIncomePage", () => { expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); - it("shows a quiet realized error", async () => { - server.use( - http.get( - "*/api/v1/brokerage-accounts/account-1/income-analytics", - () => new HttpResponse(null, { status: 503 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { - timeout: 4000, - }), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); - }); + it( + "offers retry after an initial failure and recovers in place", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + let requests = 0; + server.use( + http.get( + "*/api/v1/brokerage-accounts/account-1/income-analytics", + () => { + requests += 1; + + return isFailing + ? new HttpResponse(null, { status: 503 }) + : HttpResponse.json(resource(analytics)); + }, + ), + ); + renderPage(); + expect( + await screen.findByText( + "Не удалось рассчитать пассивный доход", + undefined, + { timeout: RETRIES_EXHAUSTED_MS }, + ), + ).toBeInTheDocument(); + const retry = screen.getByRole("button", { name: "Повторить" }); + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(retry); + + expect( + await screen.findByRole("region", { name: "Выплаты" }), + ).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached income, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get( + "*/api/v1/brokerage-accounts/account-1/income-analytics", + () => { + requests += 1; + + return isFailing + ? HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ) + : HttpResponse.json(resource(analytics)); + }, + ), + ); + const { queryClient } = renderPage(); + await screen.findByRole("region", { name: "Выплаты" }); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); }); const LocationProbe: FC = () => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index f22540d..069c915 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { @@ -32,12 +31,14 @@ type AccountIncomePageViewModel onReset: () => void; onSubmit: (values: IncomeForm) => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } | { period: IncomePeriod; + readRetry: ReturnType; state: "error"; } | { state: "loading" }; @@ -87,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { isFetching: analytics.isFetching, }); + const retry = readRetry([analytics]); + if (availability === "loading" && analytics.isLoading) return { state: "loading" }; if (!analytics.data) return availability === "unavailable" - ? { period, state: "error" } + ? { period, readRetry: retry, state: "error" } : { state: "loading" }; const display = toIncomeDisplayModel(analytics.data); @@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + readRetry: retry, source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx index bdaf3bd..742ab88 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -3,15 +3,27 @@ import type { FC } from "react"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetryButton } from "@/shared/ui"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; -const AccountIncomePageError: FC = () => { +const AccountIncomePageError: FC = (props) => { const { t } = useTranslation(); + const retry = props.readRetry.exhausted + ? ( + + ) + : undefined; + return ( = (props) => { stale={props.source?.freshness === "stale"} /> + + {t("income.taxBoundary")} 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")}} diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 37cbacf..9ab30b9 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -60,6 +60,10 @@ function configureTestAuth() { }); } +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { configureTestAuth(); @@ -1255,6 +1259,110 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); }); + it( + "keeps cached totals, then warns and retries the same portfolio GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requests += 1; + if (isFailing) + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + + return HttpResponse.json(resource( + requests === 1 + ? portfolio + : { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] }, + { resourceKey: "portfolio" }, + )); + }), + ); + const queryClient = createTestQueryClient(); + renderAccountRoute(queryClient); + await screen.findByText("Вторая"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: portfolioQueryOptions("account-1").queryKey, + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Вторая")).toBeInTheDocument(); + expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument(); + // One cached read plus the three automatic attempts. + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("999 ₽")).toBeInTheDocument(); + expect(requests).toBe(5); + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }, + ); + + it( + "offers retry after an initial temporary failure and recovers in place", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(portfolio, { resourceKey: "portfolio" })); + }), + ); + renderAccountRoute(createTestQueryClient()); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(requests).toBe(3); + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Вторая")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it.each(["NOT_FOUND", "UNAUTHORIZED"])( + "does not offer a plain retry for %s after a cached portfolio", + async (code) => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json({ code }, { status: code === "NOT_FOUND" ? 404 : 401 })), + ); + renderAccountRoute(createTestQueryClient()); + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Загрузка" })).not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }, + ); + it.each([503])( "preserves totals quietly through refresh error %i and success", async (status) => { diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index f9809bc..db138bc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -16,6 +16,7 @@ const AccountPage: FC = () => { account={viewModel.account} accountId={viewModel.accountId} incomeSummary={viewModel.incomeSummary} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} source={viewModel.source} @@ -35,6 +36,7 @@ const AccountPage: FC = () => { partial={viewModel.partial} portfolio={viewModel.portfolio} positionSections={viewModel.positionSections} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} slice={viewModel.slice} diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 622071a..77814d0 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -16,7 +16,7 @@ import { import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; -import { apiErrorCode, isBrokerageAccessLoss } from "@/shared/api"; +import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -45,6 +45,7 @@ type AccountPageContent = { accountId?: string; incomeSummary: AccountPageIncomeSummary; partial?: boolean; + readRetry: AccountPageReadRetry; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -68,10 +69,12 @@ type AccountPageLoadedViewModel = AccountPageContent & { unrealizedReturns: Portfolio["totalsByCurrency"]; }; +type AccountPageReadRetry = ReturnType; + type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel - | { code?: string; state: "error" } + | { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { state: "loading" }; function useAccountPageViewModel(): AccountPageViewModel { @@ -113,6 +116,8 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); + // The portfolio drives this screen; the retry repeats only its GET. + const retry = readRetry([portfolio]); const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ @@ -122,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel { isFetching: portfolio.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!portfolio.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); @@ -144,6 +150,7 @@ function useAccountPageViewModel(): AccountPageViewModel { account, accountId, incomeSummary, + readRetry: retry, recentEvents, recentOperations, source: portfolio.meta, @@ -181,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel { partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), + readRetry: retry, recentEvents, recentOperations, slice: current.slice, diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 510c88c..8fe13e3 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -8,7 +8,7 @@ import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { @@ -25,6 +25,7 @@ type AccountPageEmptyProps = { account?: BrokerageAccount; accountId?: string; incomeSummary?: AccountPageIncomeSummary; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -49,6 +50,8 @@ const AccountPageEmpty: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!props.readRetry && } + = (props) => { const { code } = props; @@ -43,8 +47,18 @@ const AccountPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 8348501..86bbe73 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -15,7 +15,7 @@ import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; @@ -46,6 +46,7 @@ type AccountPageLoadedProps = { partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; slice: Slice; @@ -86,6 +87,8 @@ const AccountPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!props.readRetry && } + {props.partial ? tab !== "concentration" && ( {t("investment.partial")} 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 && } + ); +}; + +const ReadRetry: FC = (props) => { + const { t } = useTranslation(); + + if (!props.exhausted) return null; + + return ( + + + {t("investment.readFailed")} + + + + + ); +}; + +export { ReadRetry, ReadRetryButton, type ReadRetryProps }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 88a1681..598d3ed 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,4 +5,9 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; +export { + ReadRetry, + ReadRetryButton, + type ReadRetryProps, +} from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";