feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
@@ -3,7 +3,6 @@ import type { FC } from "react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
@@ -115,7 +114,6 @@ describe("Upcoming event date filters", () => {
|
||||
"keeps event calendar dates in GET and source sync requests: %s",
|
||||
async (search) => {
|
||||
const requested: string[] = [];
|
||||
const syncRequests: unknown[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
@@ -133,16 +131,7 @@ describe("Upcoming event date filters", () => {
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
syncRequests.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const period = Object.fromEntries(new URLSearchParams(search));
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(
|
||||
@@ -162,15 +151,6 @@ describe("Upcoming event date filters", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("error"));
|
||||
expect(requested).toEqual([`?${search}`]);
|
||||
expect(syncRequests).toEqual([]);
|
||||
act(() => {
|
||||
if (result.current.state === "error") result.current.retry();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(syncRequests).toEqual([
|
||||
{ accountId: "all", screen: "events", ...period },
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -232,7 +212,7 @@ describe("Upcoming event date filters", () => {
|
||||
),
|
||||
);
|
||||
renderEvents(accountId, ["?from=2099-02-01"]);
|
||||
await screen.findByRole("button", { name: "Повторить" });
|
||||
await screen.findByRole("button", { name: "Сбросить" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||
await screen.findByLabelText("Период");
|
||||
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
|
||||
@@ -300,7 +280,7 @@ describe("Upcoming event date filters", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
|
||||
it("retains the previous complete forecast when its source refresh fails", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
clear: vi.fn(),
|
||||
@@ -343,9 +323,6 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
events,
|
||||
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
|
||||
refreshError: true,
|
||||
sourceError: true,
|
||||
state: "loaded",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -159,8 +159,7 @@ describe("AccountEventsPage", () => {
|
||||
expect(screen.getAllByText("O")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders empty and retryable error states", () => {
|
||||
const refetch = vi.fn();
|
||||
it("renders empty and quiet error states", async () => {
|
||||
const { rerender } = renderPage();
|
||||
expect(
|
||||
screen.getByText("За выбранный период событий не найдено"),
|
||||
@@ -169,7 +168,6 @@ describe("AccountEventsPage", () => {
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
refetch,
|
||||
} as never);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
@@ -178,8 +176,8 @@ describe("AccountEventsPage", () => {
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a structural busy state before events are available", () => {
|
||||
|
||||
@@ -6,25 +6,20 @@ import {
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
|
||||
import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib";
|
||||
|
||||
type AccountEventsPageViewModel
|
||||
= | {
|
||||
canApply: boolean;
|
||||
draftPeriod: [null | string, null | string];
|
||||
events: BrokerageEvent[];
|
||||
lastSuccessfulAt?: null | string;
|
||||
onApply: () => void;
|
||||
onPeriodChange: (value: [null | string, null | string]) => void;
|
||||
onReset: () => void;
|
||||
partial?: boolean;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => void;
|
||||
sourceError?: boolean;
|
||||
state: "loaded";
|
||||
}
|
||||
| { onReset: () => void; retry: () => void; state: "error" }
|
||||
| { onReset: () => void; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
@@ -56,7 +51,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
...(appliedTo && { to: appliedTo }),
|
||||
});
|
||||
|
||||
const sync = useScreenSync(
|
||||
useScreenSync(
|
||||
{
|
||||
accountId,
|
||||
screen: "events",
|
||||
@@ -66,7 +61,15 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
[events],
|
||||
);
|
||||
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!events.data,
|
||||
identity: scope,
|
||||
isError: events.isError || events.meta?.sync.state === "failed",
|
||||
isFetching: events.isFetching,
|
||||
});
|
||||
|
||||
if (availability === "loading" && events.isLoading)
|
||||
return { state: "loading" };
|
||||
|
||||
const onReset = () => {
|
||||
setDraftPeriod([null, null]);
|
||||
@@ -76,12 +79,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
return {
|
||||
onReset,
|
||||
retry: () => {
|
||||
void sync.retry();
|
||||
},
|
||||
state: "error",
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
if (!events.data)
|
||||
return availability === "unavailable"
|
||||
? { onReset, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
return {
|
||||
canApply:
|
||||
@@ -89,7 +92,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
&& isValidPeriod(draftFrom, draftTo),
|
||||
draftPeriod,
|
||||
events: events.data.events,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParameters({
|
||||
@@ -100,12 +102,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
onPeriodChange: setDraftPeriod,
|
||||
onReset,
|
||||
partial: events.meta?.availability !== "complete",
|
||||
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||
refreshing: events.isFetching || sync.refreshing,
|
||||
retry: () => {
|
||||
void sync.retry();
|
||||
},
|
||||
sourceError: sync.sourceError,
|
||||
state: "loaded",
|
||||
};
|
||||
}
|
||||
|
||||
+1
-3
@@ -4,7 +4,7 @@ import { Button, Group } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
|
||||
type AccountEventsPageErrorProps = { onReset: () => void };
|
||||
|
||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,8 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
<Button onClick={props.retry}>{t("events.retry")}</Button>
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
|
||||
-24
@@ -4,7 +4,6 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
BrokerageEventCard,
|
||||
brokerageEventKey,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
@@ -55,32 +53,10 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
|
||||
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
|
||||
|
||||
{!!props.refreshError && (
|
||||
<Alert aria-live="polite" color="yellow" role="status">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
|
||||
<Button onClick={props.retry} variant="subtle">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
|
||||
<Group align="end">
|
||||
|
||||
Reference in New Issue
Block a user