feat: unify upcoming event cards across pages
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
|
||||
vi.mock("@/entities/brokerage-event", () => ({
|
||||
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||
useBrokerageEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -81,6 +82,8 @@ describe("AccountEventsPage", () => {
|
||||
projectedAmount: { currency: "RUB", value: "1250" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
logoName: "ofz.png",
|
||||
isin: "RU000A0JX0J2",
|
||||
},
|
||||
{
|
||||
date: "2026-08-25",
|
||||
@@ -91,6 +94,8 @@ describe("AccountEventsPage", () => {
|
||||
projectedAmount: null,
|
||||
ticker: "OFZ",
|
||||
type: "offer",
|
||||
logoName: null,
|
||||
isin: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -98,9 +103,26 @@ describe("AccountEventsPage", () => {
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
renderPage();
|
||||
const { container } = renderPage();
|
||||
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
|
||||
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
|
||||
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
|
||||
expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible();
|
||||
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
|
||||
expect(screen.getByText("25 авг. 2026 г.")).toBeVisible();
|
||||
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
|
||||
const logo = container.querySelector("img");
|
||||
expect(logo).toHaveAttribute(
|
||||
"src",
|
||||
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
|
||||
);
|
||||
expect(screen.getAllByText("O")).toHaveLength(1);
|
||||
if (!logo) throw new Error("Expected the event logo");
|
||||
fireEvent.error(logo);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getAllByText("O")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders empty and retryable error states", () => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type AccountEventRowProps = { event: BrokerageEvent };
|
||||
|
||||
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
|
||||
const { event } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>{event.displayName}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{formatCalendarDate(event.date)} ·{" "}
|
||||
{t(`events.dateKind.${event.dateKind}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text fw={700}>
|
||||
{event.projectedAmount
|
||||
? formatMoney(event.projectedAmount)
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`events.type.${event.type}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function formatCalendarDate(date: string) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
export { AccountEventRow };
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { AccountEventRow } from "./AccountEventRow.view";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
BrokerageEventCard,
|
||||
} from "@/entities/brokerage-event";
|
||||
|
||||
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
||||
|
||||
@@ -12,12 +14,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
{props.events.map((event) => (
|
||||
<AccountEventRow
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
<Stack gap="sm">
|
||||
{props.events.map((event) => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
@@ -637,6 +638,110 @@ describe("AccountPage states", () => {
|
||||
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
|
||||
const event: BrokerageEvent = {
|
||||
type: "coupon",
|
||||
date: "2026-09-10",
|
||||
dateKind: "event",
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
ticker: "DEMO",
|
||||
displayName: "Облигация",
|
||||
logoName: null,
|
||||
isin: "RU000A0JX0J2",
|
||||
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||
};
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps()}
|
||||
recentEvents={{
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/events",
|
||||
items: [
|
||||
event,
|
||||
{
|
||||
...event,
|
||||
type: "dividend",
|
||||
date: "2026-09-11",
|
||||
dateKind: "registry",
|
||||
displayName: "Акция",
|
||||
projectedAmount: { currency: "USD", value: "0" },
|
||||
},
|
||||
{
|
||||
...event,
|
||||
type: "maturity",
|
||||
date: "2026-09-12",
|
||||
projectedAmount: { currency: "RUB", value: "1000" },
|
||||
},
|
||||
{
|
||||
...event,
|
||||
type: "offer",
|
||||
date: "2026-09-13",
|
||||
projectedAmount: 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)).sort((a, b) => a - b),
|
||||
);
|
||||
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
|
||||
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
|
||||
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
|
||||
).toBeVisible();
|
||||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||||
expect(content.getByText(amount)).toBeVisible();
|
||||
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4);
|
||||
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||||
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
content.getByRole("link", { name: "Открыть будущие события" }),
|
||||
).toHaveAttribute("href", "/accounts/account-1/events");
|
||||
});
|
||||
|
||||
it.each(["loading", "empty"] as const)(
|
||||
"keeps the upcoming %s state and calendar action",
|
||||
(state) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps()}
|
||||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||
/>,
|
||||
);
|
||||
const content = within(
|
||||
screen.getByRole("region", { name: "Будущие события" }),
|
||||
);
|
||||
if (state === "loading") {
|
||||
const skeleton = content.getByRole("status", {
|
||||
name: "Загрузка будущих событий",
|
||||
});
|
||||
expect(skeleton).toHaveAttribute("aria-busy", "true");
|
||||
expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
content.getByText("На ближайшие 30 дней событий не найдено."),
|
||||
).toBeVisible();
|
||||
}
|
||||
expect(
|
||||
content.getByRole("link", { name: "Открыть будущие события" }),
|
||||
).toHaveAttribute("href", "/accounts/account-1/events");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
brokerageAccountsQueryKey,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
type BrokerageEventsPreviewState,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
@@ -42,16 +42,7 @@ type AccountPageRecentOperations =
|
||||
items: BrokerageOperation[];
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageEvent[];
|
||||
};
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "empty"; to: string }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Stack } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -11,7 +12,6 @@ import type {
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
@@ -39,7 +39,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
text={t("portfolio.emptyText")}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
@@ -16,7 +17,6 @@ import { CurrencyCards } from "./CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
@@ -91,7 +91,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Accordion.Item value="recentEvents">
|
||||
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="incomeSummary">
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
|
||||
const syntheticEvent = {
|
||||
type: "coupon",
|
||||
date: "2026-10-15",
|
||||
dateKind: "event",
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
ticker: "DEMO",
|
||||
displayName: "Учебные облигации",
|
||||
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||
} satisfies BrokerageEvent;
|
||||
|
||||
const eventsPath = "/accounts/storybook-account/events";
|
||||
|
||||
const meta = {
|
||||
title: "Product/RecentEvents",
|
||||
component: RecentEvents,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
events: { control: false },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MemoryRouter initialEntries={["/accounts/storybook-account"]}>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Раздел ближайших событий на странице счёта WealthLedger. Раздел самостоятельно отображает загрузку, ошибку, отсутствие событий и полученные данные. Все события и суммы вымышлены. Ссылка на календарь меняет только локальный маршрут MemoryRouter; сам календарь в этой истории не отображается.",
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
events: {
|
||||
state: "loaded",
|
||||
to: eventsPath,
|
||||
items: [
|
||||
syntheticEvent,
|
||||
{
|
||||
...syntheticEvent,
|
||||
type: "dividend",
|
||||
date: "2026-11-01",
|
||||
dateKind: "registry",
|
||||
ticker: "EXAMPLE",
|
||||
displayName: "Учебные технологии",
|
||||
projectedAmount: { currency: "RUB", value: "1200" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RecentEvents>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Loaded: Story = {
|
||||
name: "События получены",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Предстоящие купоны и дивиденды показываются с ожидаемой суммой. Это прогноз событий, а не подтверждение получения дохода.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
name: "Загрузка раздела",
|
||||
args: { events: { state: "loading", to: eventsPath } },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Локальный skeleton занимает место строк событий и имеет доступное обозначение загрузки. Остальные разделы страницы могут оставаться доступными.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
name: "Ошибка получения событий",
|
||||
args: { events: { state: "error", to: eventsPath } },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Ошибка ограничена разделом событий. Сохраняется переход к полному календарю; недоступные события не выдаются за пустой список.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Нет ближайших событий",
|
||||
args: { events: { state: "empty", to: eventsPath } },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Успешно полученный пустой результат объясняется отдельным сообщением и сохраняет переход к календарю.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingAmount: Story = {
|
||||
name: "Событие без ожидаемой суммы",
|
||||
args: {
|
||||
events: {
|
||||
state: "loaded",
|
||||
to: eventsPath,
|
||||
items: [{ ...syntheticEvent, projectedAmount: null }],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Alert, Button, Paper, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||
|
||||
type RecentEventsProps = {
|
||||
events: AccountPageRecentEvents;
|
||||
};
|
||||
|
||||
type RecentEventProps = {
|
||||
event: BrokerageEvent;
|
||||
};
|
||||
|
||||
const RecentEvents: FC<RecentEventsProps> = (props) => {
|
||||
const { events } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{events.state === "loading" ? (
|
||||
<LoadingSkeleton label={t("events.recentLoading")}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={48} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
{events.state === "error" ? (
|
||||
<Alert color="red">{t("events.recentError")}</Alert>
|
||||
) : null}
|
||||
{events.state === "empty" ? (
|
||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{events.state === "loaded"
|
||||
? events.items.map((event) => (
|
||||
<RecentEvent
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
event={event}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<Button component={Link} to={events.to} variant="subtle">
|
||||
{t("events.open")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const RecentEvent: FC<RecentEventProps> = (props) => {
|
||||
const { event } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper className={classes.cash} withBorder>
|
||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
||||
<Text fw={700}>
|
||||
{event.projectedAmount
|
||||
? formatMoney(event.projectedAmount)
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { RecentEvents };
|
||||
Reference in New Issue
Block a user