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:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+16 -19
View File
@@ -256,21 +256,19 @@ describe("CardPage", () => {
}),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
);
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(
screen.queryByLabelText("Загрузка операций"),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
await screen.findByText(/Новая страница/);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const list = screen.getByText(/Новая страница/).closest("[aria-busy]");
expect(list?.parentElement?.style.minHeight).toBe("");
const newPageText = screen.getByText(/Новая страница/);
expect(newPageText).toBeInTheDocument();
expect(scroll).toHaveBeenCalledExactlyOnceWith({
behavior: "instant",
top: -400,
@@ -317,7 +315,9 @@ describe("CardPage", () => {
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
await screen.findByRole("alert");
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
@@ -331,7 +331,9 @@ describe("CardPage", () => {
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
);
await client.invalidateQueries();
await screen.findAllByRole("alert");
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument();
@@ -401,9 +403,9 @@ describe("CardPage", () => {
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
@@ -412,11 +414,6 @@ describe("CardPage", () => {
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
+2 -22
View File
@@ -75,7 +75,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")}
</Button>
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group>
)}
error
@@ -125,27 +124,16 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div>
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={vm.retryCard} />
<BankFeedback error={vm.archiveError} />
)}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
{vm.cardRefreshError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cardsFeedback && (
<BankFeedback
{...vm.cardsFeedback}
loadingError
retry={vm.retryCards}
/>
)}
@@ -187,7 +175,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
<Stack
aria-busy={vm.history.refreshing}
className={classes.operations}
data-pending={vm.history.changingPage || undefined}
gap="lg"
@@ -196,13 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack>
{vm.history.error && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
{vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group
@@ -213,7 +194,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef}
>
<Pagination
disabled={vm.history.refreshing}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
@@ -115,8 +115,6 @@ function useCardPageVm() {
error: history.isError,
loading: history.isLoading,
page: history.data?.page,
refreshing: history.isFetching,
retry: () => void history.refetch(),
},
notFound: isApiErrorCode(
apiErrorCode(card.error),
@@ -128,8 +126,6 @@ function useCardPageVm() {
page,
pageSize,
period,
retryCard: () => void card.refetch(),
retryCards: () => void cards.refetch(),
rows,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
@@ -146,7 +142,6 @@ function useCardPageVm() {
error: summary.isError,
fetching: summary.isFetching,
loading: summary.isLoading,
retry: () => void summary.refetch(),
},
toggleArchive: () => {
if (!card.data) return;
@@ -1,3 +1,4 @@
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -44,10 +45,16 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
setError(undefined);
try {
const actionId = crypto.randomUUID();
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
notifications.show({
id: "bank-operation-deleted-" + actionId,
message: t("bank.operationDeleted"),
});
setOpened(false);
}
catch (error_) {
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -112,6 +113,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
description: values.description,
};
const actionId = crypto.randomUUID();
if (props.operation)
await updateOperation.mutateAsync({
body: { ...body, version },
@@ -122,6 +125,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
id: intent,
...body,
});
notifications.show({
id: "bank-operation-saved-" + actionId,
message: t("bank.operationSaved"),
});
setOpened(false);
}
catch (error_) {