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:
+5
-17
@@ -346,7 +346,7 @@ describe("AccountOperationsPage", () => {
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByRole("button", { name: "Повторить" });
|
||||
await screen.findByRole("button", { name: "Сбросить" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||
await screen.findByLabelText("Период");
|
||||
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
|
||||
@@ -484,7 +484,9 @@ describe("AccountOperationsPage", () => {
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Не удалось загрузить журнал"),
|
||||
await screen.findByText("Не удалось загрузить журнал", undefined, {
|
||||
timeout: 4000,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -520,7 +522,7 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
|
||||
}),
|
||||
);
|
||||
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
await screen.findByRole("button", { name: "Сбросить" });
|
||||
expect(requested).toEqual([
|
||||
{
|
||||
categories: "trade",
|
||||
@@ -530,19 +532,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
|
||||
},
|
||||
]);
|
||||
expect(syncRequests).toEqual([]);
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() =>
|
||||
expect(syncRequests).toEqual([
|
||||
{
|
||||
accountId: "account-1",
|
||||
categories: ["trade"],
|
||||
from: "2024-02-29T00:00:00.000Z",
|
||||
limit: 100,
|
||||
screen: "operations",
|
||||
to: "2024-03-01T23:59:59.999Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||
@@ -595,7 +584,6 @@ it("recovers an invalid cursor at the first page without clearing applied filter
|
||||
);
|
||||
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).toHaveLength(3));
|
||||
assert.isDefined(searches[0]);
|
||||
assert.isDefined(searches[2]);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/entities/broker-operation";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
|
||||
import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib";
|
||||
|
||||
const operationCategorySet = new Set<string>(operationCategories);
|
||||
|
||||
@@ -24,7 +24,6 @@ type AccountOperationsPageViewModel
|
||||
hasPrevious: boolean;
|
||||
historyLoading?: boolean;
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: null | string;
|
||||
onApply: () => void;
|
||||
onCategoriesChange: (value: string[]) => void;
|
||||
onNext: () => void;
|
||||
@@ -32,13 +31,9 @@ type AccountOperationsPageViewModel
|
||||
onPrevious: () => 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" };
|
||||
|
||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
@@ -85,19 +80,28 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
history.reset();
|
||||
}
|
||||
|
||||
const sync = useScreenSync(
|
||||
useScreenSync(
|
||||
{ accountId, screen: "operations", ...firstPageQuery },
|
||||
[operations],
|
||||
);
|
||||
|
||||
if (operations.isLoading) return { state: "loading" };
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!operations.data,
|
||||
identity: scope,
|
||||
isError: operations.isError || operations.meta?.sync.state === "failed",
|
||||
isFetching: operations.isFetching,
|
||||
});
|
||||
|
||||
if (availability === "loading" && operations.isLoading)
|
||||
return { state: "loading" };
|
||||
|
||||
if (history.isRecoveringInvalidCursor)
|
||||
return { state: "loading" };
|
||||
|
||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||
limit: 100,
|
||||
}).queryKey;
|
||||
|
||||
const retry = () => history.retry(sync.retry);
|
||||
|
||||
const onReset = () => {
|
||||
setDraftPeriod([null, null]);
|
||||
setDraftCategories([]);
|
||||
@@ -106,16 +110,22 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||
};
|
||||
|
||||
const isInvalidCursor = isApiErrorCode(
|
||||
apiErrorCode(operations.error),
|
||||
ApiErrorCode.InvalidOperationsCursor,
|
||||
);
|
||||
|
||||
if (
|
||||
(operations.isError || operations.meta?.sync.state === "failed")
|
||||
&& (!operations.data
|
||||
|| isApiErrorCode(
|
||||
apiErrorCode(operations.error),
|
||||
ApiErrorCode.InvalidOperationsCursor,
|
||||
))
|
||||
(isInvalidCursor || !operations.data)
|
||||
&& (operations.isError || operations.meta?.sync.state === "failed")
|
||||
)
|
||||
return { onReset, retry, state: "error" };
|
||||
if (!operations.data) return { state: "loading" };
|
||||
return { onReset, state: "error" };
|
||||
|
||||
if (!operations.data)
|
||||
return availability === "unavailable"
|
||||
? { onReset, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
const nextCursor = operations.data.nextCursor;
|
||||
|
||||
const isDirty
|
||||
@@ -133,7 +143,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
operations.meta?.coverage?.historyState === "loading"
|
||||
|| operations.meta?.coverage?.historyState === "partial",
|
||||
items: operations.data.items,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParameters({
|
||||
@@ -155,10 +164,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
onPrevious: history.onPrevious,
|
||||
onReset,
|
||||
partial: operations.meta?.availability !== "complete",
|
||||
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||
refreshing: operations.isFetching || sync.refreshing,
|
||||
retry,
|
||||
sourceError: sync.sourceError,
|
||||
state: "loaded",
|
||||
};
|
||||
};
|
||||
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,21 +14,15 @@ type AccountOperationsPageErrorProps = Extract<
|
||||
const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
<Button onClick={retry}>{t("ledger.retry")}</Button>
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
)}
|
||||
error
|
||||
text={t("ledger.errorText")}
|
||||
title={t("ledger.errorTitle")}
|
||||
/>
|
||||
|
||||
-24
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Progress,
|
||||
Stack,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -14,7 +13,6 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
@@ -62,34 +60,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("ledger.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.historyLoading && <Alert>{t("investment.history")}</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>
|
||||
)}
|
||||
|
||||
<Group align="end">
|
||||
<DatePickerInput
|
||||
allowSingleDateInRange
|
||||
|
||||
Reference in New Issue
Block a user