feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m1s

This commit was merged in pull request #126.
This commit is contained in:
2026-10-30 07:28:19 +03:00
parent 6792f5b70a
commit 71126c91f0
40 changed files with 1564 additions and 102 deletions
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
@@ -44,6 +44,25 @@ const operation: BrokerageOperation
type: "OPERATION_TYPE_BUY",
};
function coupon(
id: string,
accountId: string,
accountName: string,
value: string,
): BrokerageOperation {
return {
...operation,
account: { id: accountId, name: accountName },
category: "income",
description: "Купон",
id,
instrumentUid: "UID",
payment: { currency: "RUB", value },
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -71,12 +90,12 @@ function configureTestAuth() {
});
}
function renderPage(search = "") {
function renderPage(search = "", accountId = "account-1") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]}
initialEntries={[`/accounts/${accountId}/operations${search}`]}
>
<Routes>
<Route
@@ -650,3 +669,73 @@ it("recovers an invalid cursor at the first page without clearing applied filter
for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key));
});
describe("all-account operation groups", () => {
it("groups only the current page and rebuilds the group at its boundary", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
const items = cursor
? [
coupon("X2", "iis", "ИИС", "150"),
coupon("X3", "broker", "Брокерский", "50"),
operation,
]
: [coupon("X1", "broker", "Брокерский", "100")];
return HttpResponse.json(resource({
accountId: "all",
hasNext: !cursor,
items,
nextCursor: cursor ? null : "page-2",
}));
},
),
);
renderPage("", "all");
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", {
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
});
it("leaves repeated operations ungrouped for one account", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource({
accountId: "account-1",
hasNext: false,
items: [
coupon("X1", "account-1", "Брокерский", "100"),
coupon("X2", "account-1", "Брокерский", "150"),
],
nextCursor: null,
})),
),
);
renderPage();
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ }))
.not.toBeInTheDocument();
});
});
@@ -25,6 +25,7 @@ const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel
= | {
allAccounts: boolean;
canApply: boolean;
draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string];
@@ -43,6 +44,7 @@ type AccountOperationsPageViewModel
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
@@ -147,6 +149,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|| !isSameCategories(draftCategories, appliedCategories);
return {
allAccounts: accountId === "all",
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories,
draftPeriod,
@@ -181,6 +184,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
};
};
@@ -12,7 +12,13 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import {
brokerageOperationGroupKey,
LedgerEntryRow,
operationCategories,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -24,22 +30,49 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
> = (props) => {
const { t } = useTranslation();
const { items } = props;
if (items.length > 0) {
return items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
const entries = groupItems(
items.map(operation => ({
key: JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
));
]),
operation,
})),
entry => props.allAccounts
? brokerageOperationGroupKey(entry.operation)
: null,
);
return (
<Stack
component="ul"
gap="lg"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{entries.map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
);
}
if (props.partial) {