refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -41,6 +41,7 @@ function configureTestAuth() {
function renderAccountRoute(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -56,6 +57,7 @@ function renderAccountRoute(queryClient: QueryClient) {
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -65,8 +67,10 @@ function renderPage(ui: React.ReactNode) {
);
}
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -98,8 +102,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty",
@@ -141,16 +145,20 @@ describe("AccountPage states", () => {
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position
= {
...portfolio.positions[0],
displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2",
instrumentUid: uid,
logoName: hasMetadata ? "vtoraya.png" : null,
ticker: hasMetadata ? slug : null,
type,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
@@ -168,6 +176,7 @@ describe("AccountPage states", () => {
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json(
resource({
instrumentUid: uid,
@@ -186,6 +195,7 @@ describe("AccountPage states", () => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute(
@@ -234,6 +244,7 @@ describe("AccountPage states", () => {
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
@@ -286,15 +297,12 @@ describe("AccountPage states", () => {
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"allows retry for %s",
(code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
@@ -320,9 +328,11 @@ describe("AccountPage states", () => {
})}
/>,
);
const balances = within(
screen.getByRole("region", { name: "Денежные остатки" }),
);
expect(
balances.getAllByRole("definition").map(node => node.textContent),
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
@@ -376,6 +386,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency,
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(snapshot)),
@@ -447,9 +458,11 @@ describe("AccountPage states", () => {
expect(
screen.getByRole("button", { name: "Облигации" }),
).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map(element => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByText("10 ₽")).toBeInTheDocument();
expect(screen.getByText("-1 ₽")).toBeInTheDocument();
@@ -470,7 +483,8 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -499,8 +513,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("А")).toBeInTheDocument();
@@ -513,7 +528,8 @@ describe("AccountPage states", () => {
it("groups stock supporting metrics into quiet single lines", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -530,8 +546,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -546,7 +563,8 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -587,8 +605,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -604,7 +623,8 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -633,8 +653,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
@@ -653,7 +674,8 @@ describe("AccountPage states", () => {
it("renders bond cards in expanded layout with bond-specific classes", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -670,8 +692,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("О")).toBeInTheDocument();
@@ -684,7 +707,8 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -701,8 +725,9 @@ describe("AccountPage states", () => {
type: "FUND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -713,7 +738,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
account={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account={
{
id: "account-1",
name: "Основной",
@@ -721,7 +747,8 @@ describe("AccountPage states", () => {
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
expect(
@@ -750,7 +777,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentOperations={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentOperations={
{
items: [
{
@@ -774,7 +802,8 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/operations",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
@@ -783,8 +812,9 @@ describe("AccountPage states", () => {
});
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
const event: BrokerageEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const event: BrokerageEvent
= {
date: "2026-09-10",
dateKind: "event",
displayName: "Облигация",
@@ -795,12 +825,14 @@ describe("AccountPage states", () => {
projectedAmount: { currency: "RUB", value: "850.50" },
ticker: "DEMO",
type: "coupon",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentEvents={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentEvents={
{
items: [
event,
@@ -828,19 +860,22 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/events",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
const section = screen.getByRole("region", { name: "Будущие события" });
const content = within(section);
const text = section.textContent ?? "";
const titles = [
"Купон · Облигация",
"Дивиденд · Акция",
"Погашение · Облигация",
"Оферта · Облигация",
];
for (const title of titles) expect(content.getByText(title)).toBeVisible();
expect(titles.map(title => text.indexOf(title))).toEqual(
titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b),
@@ -870,13 +905,16 @@ describe("AccountPage states", () => {
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
const content = within(
screen.getByRole("region", { name: "Будущие события" }),
);
if (state === "loading") {
const skeleton = content.getByRole("status", {
name: "Загрузка будущих событий",
});
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(skeleton).toHaveAttribute("aria-busy", "true");
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -888,6 +926,7 @@ describe("AccountPage states", () => {
content.getByText("На ближайшие 30 дней событий не найдено."),
).toBeVisible();
}
expect(
content.getByRole("link", { name: "Открыть будущие события" }),
).toHaveAttribute("href", "/accounts/account-1/events");
@@ -914,8 +953,9 @@ describe("AccountPage states", () => {
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const initialPortfolio: Portfolio
= {
...portfolio,
structure: {
currencies: [
@@ -928,8 +968,9 @@ describe("AccountPage states", () => {
],
unvaluedCategories: [{ count: 1, type: "STOCK" }],
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
@@ -944,7 +985,8 @@ describe("AccountPage states", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "overview",
@@ -956,8 +998,9 @@ describe("AccountPage states", () => {
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
@@ -965,11 +1008,14 @@ describe("AccountPage states", () => {
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3) {
const deferred = Promise.withResolvers<Response>();
resolveRetry = deferred.resolve;
return deferred.promise;
}
return HttpResponse.json(
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
{ status },
@@ -980,9 +1026,11 @@ describe("AccountPage states", () => {
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
const structure = screen.getByRole("region", {
name: "Структура портфеля",
});
expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
@@ -1057,9 +1105,11 @@ describe("AccountPage states", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
@@ -26,6 +26,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
sourceError={viewModel.sourceError}
/>
);
return (
<AccountPageLoaded
account={viewModel.account}
@@ -18,8 +18,10 @@ import {
useAccountPageViewModel,
} from "./AccountPage.vm";
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "1" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -63,8 +65,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "150" }],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function configureTestAuth() {
configureAuthClient({
@@ -76,6 +78,7 @@ function configureTestAuth() {
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = props => (
<QueryWrapper>
<MantineProvider>
@@ -87,6 +90,7 @@ function createWrapper() {
</MantineProvider>
</QueryWrapper>
);
return Wrapper;
}
@@ -97,7 +101,8 @@ function mockLoadedAccountPage() {
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
hasNext: false,
@@ -121,8 +126,9 @@ function mockLoadedAccountPage() {
],
nextCursor: null,
observedAt: "2026-08-13T10:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
@@ -130,18 +136,22 @@ function mockLoadedAccountPage() {
resource({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
displayName: `Купон ${index}`,
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
projectedAmount: { currency: "RUB", value: String(index + 1) },
ticker: `BND${index}`,
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
events: Array.from(
{ length: 6 },
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
(_, index) =>
({
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
displayName: `Купон ${index}`,
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
projectedAmount: { currency: "RUB", value: String(index + 1) },
ticker: `BND${index}`,
type: "coupon",
}),
/* eslint-enable unicorn/no-null */
),
}),
),
),
@@ -158,6 +168,7 @@ describe("useAccountPageViewModel", () => {
"DIGITAL_FINANCIAL_ASSET",
"FUND",
] as const;
const snapshot: Portfolio = {
...portfolio,
cashBalances: [{ currency: "RUB", value: "1000" }],
@@ -169,19 +180,23 @@ describe("useAccountPageViewModel", () => {
figi: "FIGI-CURRENCY",
type: "CURRENCY",
},
...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
figi: `FIGI-${type}-NEXT`,
type,
}
/* eslint-enable unicorn/no-null */))),
...otherTypes.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
type =>
({
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
figi: `FIGI-${type}-NEXT`,
type,
}),
/* eslint-enable unicorn/no-null */
),
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
};
const original = structuredClone(snapshot);
const sections = toAccountPagePositionSections(snapshot);
@@ -210,10 +225,12 @@ describe("useAccountPageViewModel", () => {
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
expect(
loaded.positionSections.map(({ groups, kind }) => ({
kind,
@@ -231,35 +248,32 @@ describe("useAccountPageViewModel", () => {
shareText: "66,7 %",
},
],
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
},
/* eslint-enable unicorn/no-null */ {
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
returnText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
returnTone: undefined,
shareText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
},
],
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
}
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null,
returnText: null,
returnTone: undefined,
shareText: null,
},
],
}
/* eslint-enable unicorn/no-null */),
]);
});
@@ -276,10 +290,12 @@ describe("useAccountPageViewModel", () => {
(result.current as { recentEvents?: { state: string } }).recentEvents,
)?.toMatchObject({ state: "loaded" }),
);
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
expect(loaded.recentOperations).toMatchObject({
items: [{ description: "Покупка акции", id: "operation-1" }],
state: "loaded",
@@ -335,11 +351,10 @@ it("keeps overview sections independent when their source data is incomplete", a
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ hasNext: false, items: [], nextCursor: null }
/* eslint-enable unicorn/no-null */),
{ availability: "partial" },
{ hasNext: false, items: [], nextCursor: null },
/* eslint-enable unicorn/no-null */ { availability: "partial" },
),
),
),
@@ -352,9 +367,11 @@ it("keeps overview sections independent when their source data is incomplete", a
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
await waitFor(() =>
expect(result.current).toMatchObject({
@@ -367,14 +384,18 @@ it("keeps overview sections independent when their source data is incomplete", a
it("refreshes every overview section through one account descriptor and observational reads", async () => {
configureTestAuth();
const sections: Record<string, unknown> = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const sections: Record<string, unknown>
= {
"events": { events: [], incomeTotalsByCurrency: [] },
"income-summary": { totalsByCurrency: [] },
"operations": { hasNext: false, items: [], nextCursor: null },
portfolio,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
@@ -385,6 +406,7 @@ it("refreshes every overview section through one account descriptor and observat
({ request }) => {
if (new URL(request.url).searchParams.get("sync") === "observe")
observed.push(path);
return HttpResponse.json(
resource(data, { resourceKey: path, stateVersion: version }),
);
@@ -394,6 +416,7 @@ it("refreshes every overview section through one account descriptor and observat
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
version = "2";
return HttpResponse.json(
{
id: "overview",
@@ -404,7 +427,8 @@ it("refreshes every overview section through one account descriptor and observat
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "overview",
@@ -416,13 +440,16 @@ it("refreshes every overview section through one account descriptor and observat
),
scope: null,
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
incomeSummary: { state: "empty" },
@@ -33,6 +33,7 @@ type AccountPageContent = {
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
@@ -47,7 +48,9 @@ type AccountPageIncomeSummary
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| { state: "empty" | "loading" | "partial"; to: string };
| { state: "empty"
| "loading"
| "partial"; to: string; };
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
@@ -85,7 +88,9 @@ type AccountPageRecentOperations
to: string;
}
| {
state: "empty" | "error" | "loading";
state: "empty"
| "error"
| "loading";
to: string;
};
@@ -97,23 +102,31 @@ type AccountPageViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
type PositionSectionKind = "bonds" | "other" | "stocks";
type PositionSectionKind = "bonds"
| "other"
| "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(item => item.account?.id !== position.account?.id)
&& group.positions.every(
item => item.account?.id !== position.account?.id,
)
) {
group.positions.push(position);
}
@@ -122,21 +135,26 @@ function groupPositions(
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()];
if (position.instrumentUid?.trim())
return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, undefined | { slug: string }> = new Map(),
instrumentDetails: Map<string, undefined
| { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
@@ -145,14 +163,17 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection(
"stocks",
portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
@@ -167,6 +188,7 @@ function toIncomeSummary(
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { retry: () => income.refetch(), state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
@@ -181,32 +203,40 @@ function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug: string | undefined,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
if (returnValue !== null && returnValue !== 0)
returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
};
}
@@ -227,6 +257,7 @@ function toRecentEvents(
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
return {
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
@@ -256,6 +287,7 @@ function toRecentOperations(
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return {
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
@@ -271,20 +303,20 @@ function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined | { slug: string }>,
instrumentDetails: Map<string, undefined
| { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: (
position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY"
);
: position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY";
})
.map(position =>
toPosition(
@@ -303,6 +335,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
portfolio.data?.positions.flatMap(position =>
position.type === "STOCK" && position.instrumentUid
@@ -310,13 +343,17 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
: [],
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
@@ -332,12 +369,15 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
state: "error" as const,
};
return { state: "loading" as const };
}
const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
const refresh = {
partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
@@ -345,6 +385,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
sourceError: sync.sourceError,
};
if (
portfolio.meta?.availability === "complete"
&& portfolio.data.positions.length === 0
@@ -359,6 +400,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
state: "empty" as const,
...refresh,
};
return {
account,
accountId,
@@ -27,7 +27,12 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
? t("accounts.all")
: (account?.name ?? t("portfolio.title"))}
</Title>
{description && <Text c="dimmed" size="sm">{description}</Text>}
{description && (
<Text c="dimmed" size="sm">
{description}
</Text>
)}
</Stack>
);
};
@@ -34,29 +34,35 @@ type AccountPageEmptyProps = {
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
const text = t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
);
return (
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
title={t("portfolio.emptyTitle")}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
structure={{ currencies: [], unvaluedCategories: [] }}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack>
);
};
@@ -25,7 +25,10 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken))
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
)
return (
<StateView
action={(
@@ -47,6 +50,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
@@ -42,18 +42,30 @@ type AccountPageLoadedProps = {
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const defaultValue = [
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
];
return (
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
{!!(props.portfolio.isStale) && ((
{!!props.portfolio.isStale && (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
))}
)}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
@@ -64,6 +76,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
{props.partial
? (
<Alert role="status">{t("investment.partial")}</Alert>
@@ -71,12 +84,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
: (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!(props.partial) && ((
{!props.partial && (
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
))}
)}
<Accordion
classNames={{
chevron: classes.accordionChevron,
@@ -84,13 +99,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
label: classes.accordionLabel,
panel: classes.accordionPanel,
}}
defaultValue={[
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
defaultValue={defaultValue}
multiple
>
{props.positionSections.map(section => (
@@ -98,35 +107,44 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<BrokerageEventsPreview events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{!!(props.incomeSummary) && ((
{!!props.incomeSummary && (
<IncomeSummary {...props.incomeSummary} />
))}
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -136,6 +154,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
@@ -19,4 +19,5 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const InitialLoading: Story = { name: "Начальная загрузка счёта" };
@@ -8,9 +8,11 @@ import classes from "../../AccountPage.module.css";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<SimpleGrid
aria-hidden="true"
cols={{ base: 1, md: 3, sm: 2 }}
@@ -19,6 +21,7 @@ const AccountPageLoading: FC = () => {
{["first", "second", "third"].map(placeholder => (
<Paper className={classes.total} key={placeholder} withBorder>
<Skeleton height={24} radius={4} width={40} />
<Skeleton
className={classes.totalAmountSkeleton}
height={21}
@@ -28,7 +31,9 @@ const AccountPageLoading: FC = () => {
</Paper>
))}
</SimpleGrid>
<Skeleton height={260} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={52} key={placeholder} radius="sm" />
))}
@@ -15,11 +15,13 @@ describe("CurrencyCards currency presentation", () => {
{ currency: "CAD", value: "4.00" },
{ currency: "XYZ", value: "5.00" },
].slice(0, count);
const { container } = render(
<MantineProvider>
<CurrencyCards amounts={totals} />
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
@@ -16,8 +16,9 @@ type CurrencyCardsProps = {
};
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { amounts } = props;
const { t } = useTranslation();
const { amounts } = props;
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
@@ -28,6 +29,7 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
className={classes.total}
@@ -40,10 +42,12 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
<VisuallyHidden>
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
@@ -51,13 +55,13 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<span className={classes.totalLeadingDigits}>
{amount.slice(0, SIGN_PREFIX_LENGTH)}
</span>
{amount.slice(SIGN_PREFIX_LENGTH)}
</>
)
: (
amount
)}
{" "}
)}{" "}
{currencies[money.currency]?.symbol ?? money.currency}
</Text>
</Paper>
@@ -50,42 +50,40 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
<Text c="dimmed">{t("income.summaryEmpty")}</Text>
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
</Stack>
);
return (
<Stack gap="xs">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
{!!(props.refreshError) && ((
)}
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.refreshError && (
<Text role="status">{t("income.refreshError")}</Text>
))}
)}
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}
:
{" "}
{formatMoney(total.coupon)}
{" "}
·
{" "}
{t("income.dividend")}
:
{" "}
{formatMoney(total.dividend)}
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
@@ -30,6 +30,7 @@ const meta = {
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...arguments_} />
</Stack>
),
@@ -40,6 +41,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
args: { refreshing: true },
@@ -24,15 +24,18 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{!!(refreshing) && ((
{!!refreshing && (
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
))}
{!!(refreshError) && ((
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
@@ -44,7 +47,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
))}
)}
</>
);
};
@@ -15,6 +15,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
hasNegativeValues: false,
totalValue: "1250000",
};
const meta = {
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
component: PortfolioStructure,
@@ -31,7 +32,9 @@ const meta = {
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Regular: Story = {};
export const MultipleCurrencies: Story = {
args: {
@@ -77,12 +80,15 @@ export const Incomplete: Story = {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
}),
/* eslint-enable unicorn/no-null */
),
},
],
unvaluedCategories: [{ count: 2, type: "STOCK" }],
@@ -103,13 +109,16 @@ export const Negative: Story = {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}),
/* eslint-enable unicorn/no-null */
),
hasNegativeValues: true,
totalValue: "1150000",
},
@@ -17,6 +17,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
hasNegativeValues: false,
totalValue: "100",
};
const ui = (structure: null | Portfolio["structure"] | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
@@ -24,7 +25,11 @@ const ui = (structure: null | Portfolio["structure"] | undefined) => (
);
describe("portfolio structure", () => {
it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])(
it.each([
undefined,
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
])(
"keeps an older snapshot without structure available (%s) and recovers on refresh",
(structure) => {
const view = render(ui(structure));
@@ -68,12 +73,15 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
}),
/* eslint-enable unicorn/no-null */
),
},
],
unvaluedCategories: [
@@ -97,13 +105,16 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}),
/* eslint-enable unicorn/no-null */
),
hasNegativeValues: true,
},
],
@@ -123,6 +134,7 @@ describe("portfolio structure", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
@@ -1,80 +1,15 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import type { FC } from "react";
import { PortfolioStructureView } from "./PortfolioStructure.view";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
import {
type PortfolioStructureProps,
usePortfolioStructureVm,
} from "./PortfolioStructure.vm";
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = selected
? selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage
= category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
item => item.type === category.type,
);
const amount = formatMoney({
currency: selected.currency,
value: category.value,
});
let displayedAmount = amount;
if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount });
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`;
return {
amount: displayedAmount,
color: presentation.color,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
})
: [];
const chartData = rows.filter(row => row.value > 0);
return (
<PortfolioStructureView
chartData={chartData}
currencies={structure?.currencies.map(item => item.currency) ?? []}
currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null}
incomplete={isIncomplete}
negative={selected?.hasNegativeValues ?? false}
onCurrencyChange={setCurrency}
rows={rows}
unavailable={!structure}
unvalued={
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? []
}
/>
);
const vm = usePortfolioStructureVm(props);
return <PortfolioStructureView {...vm} />;
};
export { PortfolioStructure };
@@ -16,6 +16,13 @@ type PortfolioStructureViewProps = {
unavailable: boolean;
unvalued: string[];
};
type PortfolioTooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: StructureRow[];
};
type StructureRow = {
amount: string;
color: string;
@@ -24,9 +31,28 @@ type StructureRow = {
value: number;
};
const PortfolioTooltip: FC<PortfolioTooltipProps> = (props) => {
const row = props.rows.find(item => item.name === props.payload?.[0]?.name);
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.name}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
const appearanceClass = `${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`;
const tooltipProps = {
content: <PortfolioTooltip rows={props.chartData} />,
};
return (
<Stack
aria-labelledby={titleId}
@@ -38,7 +64,8 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
<Title id={titleId} order={2} size="h4">
{t("portfolio.structure.title")}
</Title>
{(props.currencies.length > 1) && ((
{props.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.structure.currency")}
@@ -47,8 +74,9 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
value={props.currency}
w={112}
/>
))}
)}
</Group>
<Text c="dimmed" size="sm">
{t(
props.unavailable
@@ -56,32 +84,36 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
: "portfolio.structure.basis",
)}
</Text>
{!!(props.incomplete) && ((
{!!props.incomplete && (
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map(message => (
<Text c="dimmed" key={message} size="sm">
{message}
</Text>
))}
</Stack>
))}
{!!(props.negative) && ((
)}
{!!props.negative && (
<Text size="sm">{t("portfolio.structure.negative")}</Text>
))}
{!!(!props.unavailable
)}
{!!(
!props.unavailable
&& props.chartData.length === 0
&& !props.incomplete
&& !props.negative) && ((
&& !props.negative
) && (
<Text c="dimmed" size="sm">
{t("portfolio.structure.empty")}
</Text>
))}
<div
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
key={props.currency}
>
{props.chartData.length > 0 && ((
)}
<div className={appearanceClass} key={props.currency}>
{props.chartData.length > 0 && (
<DonutChart
aria-label={t("portfolio.structure.chartLabel")}
chartLabel={props.currency ?? undefined}
@@ -94,32 +126,11 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
strokeWidth={2}
thickness={32}
tooltipDataSource="segment"
tooltipProps={{
content: ({ active, payload }) => {
const row = props.chartData.find(
item => item.name === payload?.[0]?.name,
);
return active && row
? (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">
{row.name}
</Text>
<Text size="sm">
{row.amount}
{" "}
·
{" "}
{row.share}
</Text>
</Paper>
)
: undefined;
},
}}
tooltipProps={tooltipProps}
/>
))}
{props.rows.length > 0 && ((
)}
{props.rows.length > 0 && (
<ul
aria-label={t("portfolio.structure.categories")}
className={classes.list}
@@ -132,12 +143,15 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
bg={row.color}
className={classes.marker}
/>
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text fw={600} size="sm">
{row.amount}
</Text>
<Text c="dimmed" size="sm">
{row.share}
</Text>
@@ -145,7 +159,7 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
</li>
))}
</ul>
))}
)}
</div>
</Stack>
);
@@ -0,0 +1,108 @@
import type { TFunction } from "i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = portfolioRows(selected, structure, t);
const chartData = rows.filter(row => row.value > 0);
return {
chartData,
currencies: structure?.currencies.map(item => item.currency) ?? [],
// eslint-disable-next-line unicorn/no-null -- Mantine clears the controlled currency with null.
currency: selected?.currency ?? null,
incomplete: isIncomplete,
negative: selected?.hasNegativeValues ?? false,
onCurrencyChange: setCurrency,
rows,
unavailable: !structure,
unvalued:
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? [],
};
};
function portfolioRows(
selected:
NonNullable<Portfolio["structure"]>["currencies"][number] | undefined,
structure: PortfolioStructureProps["structure"],
t: TFunction,
) {
if (!selected) return [];
return selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage
= category.sharePercent === null
? undefined
: Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
item => item.type === category.type,
);
let amount = formatMoney({
currency: selected.currency,
value: category.value,
});
if (unknown) {
amount
= category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount });
}
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== undefined) {
if (percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT)
share = t("portfolio.structure.smallShare");
else
share = `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
}).format(percentage)} %`;
}
return {
amount,
color: presentation.color,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
});
}
export { type PortfolioStructureProps, usePortfolioStructureVm };
@@ -4,24 +4,25 @@ import type { AccountPagePosition } from "../../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: { currency: "RUB", value: "5000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
currentValue: { currency: "RUB", value: "25000" },
displayName: "Учебные технологии",
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const syntheticPosition
= {
absoluteReturn: { currency: "RUB", value: "5000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
currentValue: { currency: "RUB", value: "25000" },
displayName: "Учебные технологии",
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
} satisfies /* eslint-enable unicorn/no-null */ AccountPagePosition;
const meta = {
args: { kind: "stocks", position: syntheticPosition },
@@ -61,8 +62,10 @@ export const StockGain: Story = {
},
};
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const BondLoss: Story
= {
args: {
kind: "bonds",
position: {
@@ -70,7 +73,8 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
absoluteReturn: { currency: "RUB", value: "-500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
currentValue: { currency: "RUB", value: "9500" },
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
displayName:
"Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
returnText: "−500 ₽",
@@ -85,15 +89,17 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
docs: {
description: {
story:
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Other: Story
= {
args: {
kind: "other",
position: {
@@ -109,15 +115,17 @@ export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
docs: {
description: {
story:
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const MissingValuation: Story
= {
args: {
position: {
...syntheticPosition,
@@ -136,9 +144,9 @@ export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fix
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
@@ -23,20 +23,26 @@ const PositionCard: FC<PositionCardProps> = (props) => {
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
let returnText = position.returnText;
if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText });
if (returnText && !isExpanded)
returnText = t("portfolio.return", { value: returnText });
const instrumentTo = position.instrumentTo;
const identifier
= kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
]
.filter(Boolean)
.join(" · ");
const accountLink = position.account
? (
<Anchor
@@ -48,15 +54,16 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Anchor>
)
: undefined;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
position.shareText
? t("portfolio.share", { value: position.shareText })
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
]
.filter(Boolean)
.join(" · ");
@@ -71,6 +78,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
src={position.logoSrc}
to={instrumentTo}
/>
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
{instrumentTo
? (
@@ -83,31 +91,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
{position.displayName}
</Text>
)}
{accountLink}
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
{identifierText}
</Text>
</Stack>
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
{!!(position.currentValue) && ((
{!!position.currentValue && (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
</Text>
))}
{!!(returnText) && ((
)}
{!!returnText && (
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
))}
)}
</Stack>
</div>
{!!(secondaryText) && ((
{!!secondaryText && (
<Text c="dimmed" className={classes.expandedMeta} size="xs">
{secondaryText}
</Text>
))}
)}
</Card>
);
return (
<Card className={classes.tile} withBorder>
<Group align="flex-start" justify="space-between" wrap="nowrap">
@@ -115,45 +129,52 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{!!(position.isin) && ((
{!!position.isin && (
<Text c="dimmed" className={classes.mono} size="xs">
{position.isin}
</Text>
))}
)}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack align="flex-end" gap={2}>
{!!(position.currentValue) && ((
{!!position.currentValue && (
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
))}
{!!(returnText) && ((
)}
{!!returnText && (
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
))}
)}
</Stack>
</Group>
{!!(position.averagePurchasePrice || position.shareText) && ((
{!!(position.averagePurchasePrice || position.shareText) && (
<Group justify="space-between" mt="xs">
{!!(position.averagePurchasePrice) && ((
{!!position.averagePurchasePrice && (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
))}
{!!(position.shareText) && ((
)}
{!!position.shareText && (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
))}
)}
</Group>
))}
)}
</Card>
);
};
@@ -24,17 +24,19 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { t } = useTranslation();
const identity = group.positions[0];
const instrumentTo = identity.instrumentTo;
return (
<Card className={classes.tile} withBorder>
<Group gap="sm" wrap="nowrap">
{kind !== "other" && ((
{kind !== "other" && (
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
label={identity.displayName}
src={identity.logoSrc}
to={instrumentTo}
/>
))}
)}
<Stack gap={2} miw={0}>
{instrumentTo
? (
@@ -45,72 +47,80 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
: (
<Text fw={600}>{identity.displayName}</Text>
)}
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
</Stack>
</Group>
<Stack
aria-label={t("portfolio.byAccount")}
className={classes.accountPositions}
component="ul"
gap={0}
>
{group.positions.map(position => (
<Stack
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group align="flex-start" gap="xs" justify="space-between">
{!!(position.account) && ((
<Anchor
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
))}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group gap="xs" justify="space-between">
{!!(position.currentValue) && ((
<Text size="sm">{formatMoney(position.currentValue)}</Text>
))}
{!!(position.returnText) && ((
<Text
className={
(position.returnTone) ? (classes[position.returnTone]) : undefined
}
size="sm"
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
{group.positions.map((position) => {
const appearanceClass = position.returnTone
? classes[position.returnTone]
: undefined;
return (
<Stack
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group align="flex-start" gap="xs" justify="space-between">
{!!position.account && (
<Anchor
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
)}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
))}
</Group>
<Group gap="xs">
{!!(position.averagePurchasePrice) && ((
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
))}
</Group>
</Stack>
))}
</Group>
<Group gap="xs" justify="space-between">
{!!position.currentValue && (
<Text size="sm">{formatMoney(position.currentValue)}</Text>
)}
{!!position.returnText && (
<Text className={appearanceClass} size="sm">
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
)}
</Group>
<Group gap="xs">
{!!position.averagePurchasePrice && (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
)}
{!!position.shareText && (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
)}
</Group>
</Stack>
);
})}
</Stack>
</Card>
);
@@ -1,9 +1,10 @@
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
import { useImageFallback } from "@/shared/lib";
type PositionLogoProps = {
label: string;
src: null | string;
@@ -12,41 +13,36 @@ type PositionLogoProps = {
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { label, src } = props;
const [failed, setFailed] = useState(false);
const { failed, onError } = useImageFallback();
const logo
= src && !failed
? (
<Avatar
alt={label}
onError={() => setFailed(true)}
radius="md"
size={40}
src={src}
/>
<Avatar alt={label} onError={onError} radius="md" size={40} src={src} />
)
: (
<Avatar radius="md" size={40}>
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return props.to
? (
<Anchor
aria-label={label}
c="inherit"
component={Link}
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
to={props.to}
underline="never"
>
{logo}
</Anchor>
)
: (
logo
);
if (props.to) return (
<Anchor
aria-label={label}
c="inherit"
component={Link}
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
to={props.to}
underline="never"
>
{logo}
</Anchor>
);
return (
logo
);
};
export { PositionLogo };
@@ -15,24 +15,27 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const isExpanded = section.kind !== "other";
const cols = isExpanded ? 1 : { base: 1, md: 3, sm: 2 };
return (
<SimpleGrid cols={isExpanded ? 1 : { base: 1, md: 3, sm: 2 }}>
{section.groups.map(group =>
group.positions.length > 1
? (
<PositionGroupCard
group={group}
key={group.key}
kind={section.kind}
/>
)
: (
<PositionCard
key={group.key}
kind={section.kind}
position={group.positions[0]}
/>
),
<SimpleGrid cols={cols}>
{section.groups.map((group) => {
if (group.positions.length > 1) return (
<PositionGroupCard
group={group}
key={group.key}
kind={section.kind}
/>
);
return (
<PositionCard
key={group.key}
kind={section.kind}
position={group.positions[0]}
/>
);
},
)}
</SimpleGrid>
);
@@ -15,54 +15,61 @@ type RecentOperationsProps = {
};
const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { operations } = props;
const { t } = useTranslation();
const { operations } = props;
return (
<Stack gap="sm">
{(operations.state === "loaded") && ((
{operations.state === "loaded" && (
<>
{!!(operations.lastSuccessfulAt) && ((
{!!operations.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
))}
{!!(operations.stale) && ((
<Text size="xs">{t("investment.stale")}</Text>
))}
{!!(operations.refreshError) && ((
)}
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!operations.refreshError && (
<Text role="status">{t("accounts.refreshError")}</Text>
))}
)}
</>
))}
{(operations.state === "loading") && ((
)}
{operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{["first", "second", "third"].map(placeholder => (
<Skeleton height={48} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
))}
{(operations.state === "error") && ((
)}
{operations.state === "error" && (
<Alert color="red">{t("ledger.recentError")}</Alert>
))}
{(operations.state === "empty") && ((
)}
{operations.state === "empty" && (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
))}
{(operations.state === "partial") && ((
)}
{operations.state === "partial" && (
<Text role="status">{t("investment.partial")}</Text>
))}
{(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
)))}
)}
{(operations.state === "loaded" || operations.state === "partial")
&& operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))}
<Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")}
</Button>
@@ -8,8 +8,10 @@ import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position: Portfolio["positions"][number]
= {
absoluteReturn: { currency: "RUB", value: "25" },
account: { id: "a", name: "Основной" },
averagePurchasePrice: { currency: "RUB", value: "280" },
@@ -21,18 +23,21 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
quantity: "1.25",
ticker: "SBER",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
const second = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const second
= {
...position,
absoluteReturn: null,
account: { id: "b", name: "ИИС" },
averagePurchasePrice: null,
currentValue: null,
quantity: "-2",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
cashBalances: [],
@@ -55,9 +60,11 @@ describe("Positions grouped by security", () => {
</MantineProvider>,
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
const rows = within(
screen.getByRole("list", { name: "Позиции по счетам" }),
).getAllByRole("listitem");
expect(rows).toHaveLength(2);
expect(
within(rows[0]).getByRole("link", { name: "Основной" }),
@@ -77,26 +84,31 @@ describe("Positions grouped by security", () => {
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
sections([
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null }
/* eslint-enable unicorn/no-null */),
{
...position,
figi: null,
},
/* eslint-enable unicorn/no-null */ { ...second, figi: null }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
])[0].groups,
).toHaveLength(1);
expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
).toHaveLength(2);
expect(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
sections([
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
{
...position,
figi: null,
isin: null,
},
/* eslint-enable unicorn/no-null */ {
...second,
figi: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
isin: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
},
])[0].groups,
).toHaveLength(2);
});
@@ -122,6 +134,7 @@ describe("Positions grouped by security", () => {
currentValue: { currency: "USD", value: "1.01" },
},
];
const before = structuredClone(source);
const [group] = sections(source)[0].groups;
expect(
@@ -23,27 +23,34 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
"bonds",
"other",
];
const width = window.innerWidth;
expect([375, 1280]).toContain(width);
for (const kind of kinds) {
const otherType = kind === "bonds" ? "BOND" : "FUND";
const positions: AccountPagePosition[] = [1, 2, 3].map(index => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "1000" },
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnText: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
}
/* eslint-enable unicorn/no-null */)));
const positions: AccountPagePosition[] = [1, 2, 3].map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
index =>
({
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "1000" },
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnText: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
}),
/* eslint-enable unicorn/no-null */
);
const { unmount } = render(
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
@@ -61,15 +68,19 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
/>
</MantineProvider>,
);
const bounds = positions.map(position =>
screen.getByText(position.displayName).getBoundingClientRect(),
);
const context = `${kind} at ${width}px`;
for (const [index, current] of bounds.entries()) {
expect(current.width, context).toBeGreaterThan(0);
expect(current.height, context).toBeGreaterThan(0);
if (index === 0) continue;
const previous = bounds[index - 1];
if (kind === "other" && width === 1280) {
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.top, context).toBeCloseTo(previous.top, 0);
@@ -83,6 +94,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
expect(current.top, context).toBeGreaterThan(previous.bottom);
}
}
unmount();
}
});