feat: improve card operations pagination and scroll behavior

This commit is contained in:
2026-09-12 20:31:44 +03:00
parent ae06f6e6e0
commit 9a65687d38
7 changed files with 431 additions and 128 deletions
@@ -120,6 +120,45 @@ function useReadModels() {
describe("banking API", () => {
beforeEach(authenticate);
it("loads every page for all mode and fails without exposing partial results", async () => {
const pages: number[] = [];
let fail = false;
server.use(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page"));
pages.push(page);
expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09");
if (fail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({
items: [{ ...transfer, id: String(page) }],
total: 2,
page,
hasNext: page === 1,
});
}),
);
const { result } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map((item) => item.id)).toEqual([
"1",
"2",
]);
fail = true;
const failed = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(failed.result.current.isError).toBe(true));
expect(failed.result.current.data).toBeUndefined();
});
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
const counts = new Map<string, number>();
server.use(...readHandlers(counts));
+49 -6
View File
@@ -1,4 +1,5 @@
import {
apiClient,
apiQueryClient,
type paths,
useAuthenticatedQuery,
@@ -8,6 +9,8 @@ type BankCard =
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation =
paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations =
paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary =
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type ExpenseCategory =
@@ -52,14 +55,54 @@ function useBankCategories() {
function useBankOperations(
id: string | undefined,
query: { month: string; page: number; limit?: number },
query: { month: string; page: number; limit?: number | "all" },
) {
const all = query.limit === "all";
const params = {
path: { id: id ?? "" },
query: {
...query,
page: all ? 1 : query.page,
limit: query.limit === "all" ? 100 : query.limit,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params },
);
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params: { path: { id: id ?? "" }, query } },
),
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !all &&
previousQuery?.queryKey.length === 3 &&
previous?.path?.id === id &&
previous?.query?.month === query.month &&
previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = params.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...params, query: { ...params.query, page } },
signal,
},
);
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!all) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, items, page: 1, hasNext: false };
}
},
enabled: Boolean(id && query.month),
});
}
+20
View File
@@ -17,3 +17,23 @@
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
.operations[data-pending] {
opacity: 0.55;
}
@media (prefers-reduced-motion: no-preference) {
.operations {
animation: operations-appear 200ms ease-out;
transition: opacity 150ms ease;
}
@keyframes operations-appear {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
+106 -6
View File
@@ -9,7 +9,7 @@ import {
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
@@ -71,10 +71,15 @@ function setup(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
items: [operation],
page: 1,
total: 2,
hasNext: true,
items: [
{
...operation,
id: `operation-${new URL(request.url).searchParams.get("page")}`,
},
],
page: Number(new URL(request.url).searchParams.get("page")),
total: 30,
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
});
}),
http.get("*/api/v1/bank-cards/summary", () =>
@@ -93,7 +98,7 @@ function setup(
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
@@ -114,10 +119,22 @@ describe("CardPage", () => {
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(requests.every((value) => value.includes("limit=10"))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
);
const lastPage = await screen.findByRole("button", { name: "Страница 3" });
await waitFor(() => expect(lastPage).toBeEnabled());
fireEvent.click(lastPage);
await waitFor(() => expect(requests.at(-1)).toContain("page=3"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
);
expect(
screen.getByRole("button", { name: "Страница 3" }),
).toHaveTextContent("3");
fireEvent.click(screen.getByLabelText("Месяц"));
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
await waitFor(() =>
@@ -129,6 +146,89 @@ describe("CardPage", () => {
);
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
const scroll = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
setup();
await screen.findByText("Перевод на «Запасная»");
const navigation = screen.getByRole("navigation", {
name: "Страницы операций",
});
const measure = vi
.spyOn(navigation, "getBoundingClientRect")
.mockReturnValue({ top: 600 } as DOMRect);
let release = () => {};
const pending = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get("*/api/v1/bank-cards/a/operations", async () => {
await pending;
return HttpResponse.json({
items: [{ ...operation, id: "next", description: "Новая страница" }],
page: 2,
total: 30,
hasNext: true,
});
}),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
);
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(
screen.queryByLabelText("Загрузка операций"),
).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
await screen.findByText(/Новая страница/);
const list = screen.getByText(/Новая страница/).closest("[aria-busy]");
expect(list?.parentElement?.style.minHeight).toBe("");
expect(scroll).toHaveBeenCalledExactlyOnceWith({
top: -400,
behavior: "instant",
});
measure.mockRestore();
scroll.mockRestore();
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
});
it("changes page size, resets the page and shows all monthly operations", async () => {
const { requests } = setup();
await screen.findByText("Перевод на «Запасная»");
fireEvent.click(screen.getByRole("button", { name: "Страница 3" }));
await waitFor(() => expect(requests.at(-1)).toContain("page=3"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
fireEvent.click(
screen.getByRole("combobox", { name: "Операций на странице" }),
);
fireEvent.click(await screen.findByRole("option", { name: "20" }));
await waitFor(() => {
expect(requests.at(-1)).toContain("limit=20");
expect(requests.at(-1)).toContain("page=1");
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
fireEvent.click(
screen.getByRole("combobox", { name: "Операций на странице" }),
);
fireEvent.click(await screen.findByRole("option", { name: "Все" }));
await waitFor(() =>
expect(
screen.queryByRole("navigation", { name: "Страницы операций" }),
).not.toBeInTheDocument(),
);
await waitFor(() => expect(requests.at(-1)).toContain("limit=100"));
await waitFor(() =>
expect(screen.getAllByText("Перевод на «Запасная»")).toHaveLength(3),
);
});
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
+189 -112
View File
@@ -3,14 +3,16 @@ import {
Anchor,
Button,
Group,
Pagination,
Paper,
Select,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import type { FC } from "react";
import { type FC, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
@@ -28,6 +30,34 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPage = useRef<{
scope: string;
page: number;
top: number;
} | null>(null);
const scope = `${card?.id}-${vm.month}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPage.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPage.current = null;
return;
}
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
return;
const pagination = paginationRef.current;
if (pagination) {
const delta = pagination.getBoundingClientRect().top - pending.top;
if (delta) window.scrollBy({ top: delta, behavior: "instant" });
}
pendingPage.current = null;
}, [
scope,
vm.history.data,
vm.history.isPlaceholderData,
vm.history.isError,
]);
if (!card) {
if (vm.card.isLoading)
return (
@@ -128,102 +158,135 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
error={vm.summary.isError}
retry={() => void vm.summary.refetch()}
/>
<Title order={2}>{t("bank.operations")}</Title>
{vm.rows ? (
vm.rows.length ? (
vm.rows.map((row) => (
<section
key={row.item.id}
className={classes.operation}
aria-label={`${row.title} ${row.item.date}`}
>
<EntryCard
title={row.title}
metadata={row.metadata}
color={row.color}
initial={row.title[0]}
icon={
<BankOperationIcon
kind={row.item.kind}
categoryName={row.item.categoryName}
/>
}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
tone={row.tone}
details={
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?month=${vm.month}`}
>
{t("bank.card")}
</Anchor>
) : null
}
actions={
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
disabled={!row.editable || Boolean(card.archivedAt)}
size="sm"
aria-label={t("bank.edit")}
title={t("bank.edit")}
onClick={() => vm.setOperation(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<Group justify="space-between" align="end">
<Title order={2}>{t("bank.operations")}</Title>
<Select
label={t("bank.pageSize")}
value={vm.pageSize}
onChange={vm.setPageSize}
allowDeselect={false}
w={180}
data={[
"5",
"10",
"20",
"50",
"100",
{ value: "all", label: t("bank.allOperations") },
]}
/>
</Group>
<Stack
key={`${vm.month}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
gap="lg"
className={classes.operations}
aria-busy={vm.history.isFetching}
data-pending={vm.history.isPlaceholderData || undefined}
>
{vm.rows ? (
vm.rows.length ? (
vm.rows.map((row) => (
<section
key={row.item.id}
className={classes.operation}
aria-label={`${row.title} ${row.item.date}`}
>
<EntryCard
title={row.title}
metadata={row.metadata}
color={row.color}
initial={row.title[0]}
icon={
<BankOperationIcon
kind={row.item.kind}
categoryName={row.item.categoryName}
/>
}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
tone={row.tone}
details={
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?month=${vm.month}`}
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
disabled={!row.editable || Boolean(card.archivedAt)}
size="sm"
aria-label={t("bank.delete")}
title={t("bank.delete")}
onClick={() => vm.setDeleting(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{t("bank.card")}
</Anchor>
) : null
}
actions={
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
disabled={
vm.history.isPlaceholderData ||
!row.editable ||
Boolean(card.archivedAt)
}
size="sm"
aria-label={t("bank.edit")}
title={t("bank.edit")}
onClick={() => vm.setOperation(row.item)}
>
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
</svg>
</ActionIcon>
</Group>
}
/>
</section>
))
) : (
<Text>{t("bank.noOperations")}</Text>
)
) : vm.history.isLoading ? (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
) : null}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
disabled={
vm.history.isPlaceholderData ||
!row.editable ||
Boolean(card.archivedAt)
}
size="sm"
aria-label={t("bank.delete")}
title={t("bank.delete")}
onClick={() => vm.setDeleting(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
</svg>
</ActionIcon>
</Group>
}
/>
</section>
))
) : (
<Text>{t("bank.noOperations")}</Text>
)
) : vm.history.isLoading ? (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
) : null}
</Stack>
{vm.history.isError && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
@@ -232,23 +295,37 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Button>
</Group>
)}
{vm.history.data && (
<Group>
<Button
variant="default"
disabled={vm.page === 1 || vm.history.isFetching}
onClick={() => vm.setPage(vm.page - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: vm.page })}</Text>
<Button
variant="default"
disabled={!vm.history.data.hasNext || vm.history.isFetching}
onClick={() => vm.setPage(vm.page + 1)}
>
{t("bank.next")}
</Button>
{vm.history.data && vm.pageSize !== "all" && (
<Group
component="nav"
ref={paginationRef}
aria-label={t("bank.pagination")}
justify="center"
py="sm"
>
<Pagination
total={vm.totalPages}
value={vm.page}
onChange={(page) => {
if (paginationRef.current)
pendingPage.current = {
scope,
page,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
}}
disabled={vm.history.isFetching}
siblings={0}
getItemProps={(page) => ({
"aria-label": t("bank.page", { page }),
})}
getControlProps={(control) => ({
"aria-label": t(
control === "previous" ? "bank.previous" : "bank.next",
),
})}
/>
</Group>
)}
</Stack>
+25 -4
View File
@@ -13,18 +13,29 @@ import {
import { apiErrorCode } from "@/shared/api";
import { formatDate, formatMoney } from "@/shared/lib";
const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"];
function useCardPageVm() {
const { cardId } = useParams();
const [params, setParams] = useSearchParams();
const { t } = useTranslation();
const month = bankMonth(params.get("month"));
const requestedSize = params.get("limit") ?? "10";
const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10";
const rawPage = Number(params.get("page") ?? 1);
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
const page =
pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
? rawPage
: 1;
const card = useBankCard(cardId);
const { updateCard } = useBankMutations();
const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true);
const history = useBankOperations(cardId, { month, page });
const history = useBankOperations(cardId, {
month,
page,
limit: pageSize === "all" ? "all" : Number(pageSize),
});
const summary = useBankSummary(month, cardId);
const [editingCard, setEditingCard] = useState(false);
const [operation, setOperation] = useState<BankOperation | "new" | null>(
@@ -85,6 +96,11 @@ function useCardPageVm() {
summary,
month,
page,
pageSize,
totalPages:
pageSize === "all"
? 1
: Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))),
rows,
editingCard,
setEditingCard,
@@ -94,9 +110,14 @@ function useCardPageVm() {
setDeleting,
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
setMonth: (value: string) => {
if (value) setParams({ month: bankMonth(value) });
if (value) setParams({ month: bankMonth(value), limit: pageSize });
},
setPage: (value: number) => setParams({ month, page: String(value) }),
setPageSize: (value: string | null) => {
if (value && PAGE_SIZES.includes(value))
setParams({ month, limit: value });
},
setPage: (value: number) =>
setParams({ month, page: String(value), limit: pageSize }),
};
}
type CardPageVm = ReturnType<typeof useCardPageVm>;
+3
View File
@@ -96,6 +96,9 @@ const ru = {
previous: "Назад",
next: "Далее",
page: "Страница {{page}}",
pagination: "Страницы операций",
pageSize: "Операций на странице",
allOperations: "Все",
transferTo: "Перевод на «{{name}}»",
transferFrom: "Перевод с «{{name}}»",
edit: "Изменить",