feat: add Storybook for WealthLedger components

This commit is contained in:
2026-09-07 22:22:37 +03:00
parent 9e42148f6a
commit e63fd89a39
18 changed files with 2889 additions and 22 deletions
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
const syntheticAccount = {
id: "storybook-account",
name: "Учебный брокерский счёт",
type: "BROKERAGE",
openedOn: "2025-03-15",
portfolioSummary: {
totalsByCurrency: [
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
{ currency: "EUR", value: "210" },
],
},
} satisfies BrokerageAccount;
const meta = {
title: "WealthLedger/BrokerageAccountList",
component: BrokerageAccountList,
tags: ["autodocs"],
argTypes: {
accounts: { control: false },
},
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/accounts"]}>
<Story />
</MemoryRouter>
),
],
parameters: {
docs: {
description: {
component:
"Карточки счетов WealthLedger со сводкой по валютам. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
},
},
},
args: { accounts: brokerageAccountCards([syntheticAccount]) },
} satisfies Meta<typeof BrokerageAccountList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Available: Story = {
name: "Сводка по нескольким валютам",
parameters: {
docs: {
description: {
story:
"Показываются первые две валюты и количество остальных. Суммы разных валют не складываются в общий итог.",
},
},
},
};
export const Unavailable: Story = {
name: "Сводка недоступна",
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, portfolioSummary: null, openedOn: null },
]),
},
parameters: {
docs: {
description: {
story:
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
},
},
},
};
export const Empty: Story = {
name: "Нет текущих сумм",
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, portfolioSummary: { totalsByCurrency: [] } },
]),
},
parameters: {
docs: {
description: {
story:
"Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.",
},
},
},
};
export const LongName: Story = {
name: "Длинное название счёта",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений",
type: "INDIVIDUAL_INVESTMENT",
},
]),
},
parameters: {
docs: {
description: {
story:
"Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.",
},
},
},
};
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AccountPageLoading } from "./AccountPageLoading.view";
const meta = {
title: "WealthLedger/AccountPageLoading",
component: AccountPageLoading,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Начальная загрузка страницы счёта без доступных данных: настоящий page-owned skeleton заголовка, сводки и строк. Shared LoadingSkeleton предоставляет busy-region, фигуры декоративны и не получают фокус. Не используйте вместо сохранённых данных при refetch; запросы в витрине не выполняются.",
},
},
},
} satisfies Meta<typeof AccountPageLoading>;
export default meta;
type Story = StoryObj<typeof meta>;
export const InitialLoading: Story = { name: "Начальная загрузка счёта" };
@@ -0,0 +1,49 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioTotals } from "./PortfolioTotals.view";
const meta = {
title: "WealthLedger/PortfolioRefreshState",
component: PortfolioRefreshState,
tags: ["autodocs"],
args: { refreshing: false, refreshError: false, retry: fn() },
argTypes: {
refreshing: { control: "boolean" },
refreshError: { control: "boolean" },
retry: { control: false },
},
parameters: {
docs: {
description: {
component:
"Обновление уже показанных данных портфеля. Реальные PortfolioTotals остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
},
},
},
render: (args) => (
<Stack>
<PortfolioTotals
totals={[
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...args} />
</Stack>
),
} satisfies Meta<typeof PortfolioRefreshState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
name: "Фоновое обновление",
args: { refreshing: true },
};
export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
};
@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AccountPagePosition } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = {
figi: null,
displayName: "Учебные технологии",
type: "STOCK",
quantity: "100",
currentValue: { currency: "RUB", value: "25000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
absoluteReturn: { currency: "RUB", value: "5000" },
isin: null,
ticker: "DEMO",
logoName: null,
shareText: "20%",
returnText: "+5 000 ₽",
returnTone: "positive",
logoSrc: null,
} satisfies AccountPagePosition;
const meta = {
title: "WealthLedger/PositionCard",
component: PositionCard,
tags: ["autodocs"],
argTypes: {
kind: {
control: "inline-radio",
options: ["stocks", "bonds", "other"],
},
position: { control: false },
},
parameters: {
docs: {
description: {
component:
"Позиция портфеля WealthLedger: количество, стоимость, результат и доля. Доля и текст результата поступают готовыми из модели представления. Карточка не рассчитывает доходность. Все данные вымышлены; логотип заменён буквенным обозначением.",
},
},
},
args: { kind: "stocks", position: syntheticPosition },
} satisfies Meta<typeof PositionCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const StockGain: Story = {
name: "Акция с положительным результатом",
parameters: {
docs: {
description: {
story:
"Развёрнутая карточка акции показывает тикер, оценку позиции и положительный результат. Цвет дополняет знак суммы.",
},
},
},
};
export const BondLoss: Story = {
name: "Облигация с отрицательным результатом",
args: {
kind: "bonds",
position: {
...syntheticPosition,
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
type: "BOND",
quantity: "10",
currentValue: { currency: "RUB", value: "9500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
absoluteReturn: { currency: "RUB", value: "-500" },
isin: "DEMO-BOND-2030",
ticker: null,
shareText: "7,6%",
returnText: "−500 ₽",
returnTone: "negative",
},
},
parameters: {
docs: {
description: {
story:
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
},
},
},
};
export const Other: Story = {
name: "Прочий инструмент",
args: {
kind: "other",
position: {
...syntheticPosition,
displayName: "Учебный фонд",
type: "FUND",
isin: "DEMO-FUND",
ticker: null,
},
},
parameters: {
docs: {
description: {
story:
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
},
},
},
};
export const MissingValuation: Story = {
name: "Оценка и идентификаторы отсутствуют",
args: {
position: {
...syntheticPosition,
displayName: "Учебная позиция без текущей оценки",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
ticker: null,
shareText: null,
returnText: null,
returnTone: undefined,
},
},
parameters: {
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
},
},
},
};
@@ -0,0 +1,133 @@
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: "WealthLedger/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:
"Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
},
},
},
};
+78
View File
@@ -0,0 +1,78 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { fn } from "storybook/test";
import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void;
};
const meta = {
title: "WealthLedger/StateView",
component: StateView,
tags: ["autodocs"],
args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false,
withAction: false,
onAction: fn(),
},
argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } },
withAction: {
control: "boolean",
description: "Показать необязательное действие StateView",
},
onAction: { control: false },
},
parameters: {
docs: {
description: {
component:
"Общее empty/error состояние WealthLedger с необязательным действием. Error сохраняет alert semantics. Для initial loading используйте skeleton страницы, для refetch — локальное предупреждение рядом с сохранёнными данными. Кнопка вызывает только Actions; API recovery и полный пользовательский сценарий здесь не проверяются.",
},
},
},
render: (args) => {
const { withAction, onAction, ...stateProps } = args;
return (
<StateView
{...stateProps}
action={
withAction ? (
<Button onClick={onAction} variant="default">
{args.error ? "Повторить локально" : "Выполнить пример"}
</Button>
) : undefined
}
/>
);
},
} satisfies Meta<StateStoryArgs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = {
name: "Пустое состояние с действием",
args: { withAction: true },
};
export const Failed: Story = {
name: "Ошибка",
args: {
error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.",
},
};
export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true },
};
+60
View File
@@ -0,0 +1,60 @@
import { composeStories } from "@storybook/react-vite";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { fn } from "storybook/test";
import { afterEach, describe, expect, it, vi } from "vitest";
import preview from "../../.storybook/preview";
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList.stories";
import * as LoadingStories from "../pages/account/ui/components/AccountPageLoading.stories";
import * as RefreshStories from "../pages/account/ui/components/PortfolioRefreshState.stories";
import * as PositionStories from "../pages/account/ui/components/PositionCard.stories";
import * as EventStories from "../pages/account/ui/components/RecentEvents.stories";
import * as StateStories from "../shared/ui/StateView.stories";
afterEach(() => vi.restoreAllMocks());
describe("Storybook isolated product examples", () => {
it("renders every fixture and runs callbacks without application network or browser data", () => {
const spies = [
vi.spyOn(globalThis, "fetch"),
vi.spyOn(XMLHttpRequest.prototype, "open"),
vi.spyOn(Storage.prototype, "getItem"),
vi.spyOn(Storage.prototype, "setItem"),
vi.spyOn(Storage.prototype, "removeItem"),
vi.spyOn(Document.prototype, "cookie", "get"),
vi.spyOn(Document.prototype, "cookie", "set"),
];
const stories = [
...Object.values(composeStories(AccountStories, preview)),
...Object.values(composeStories(LoadingStories, preview)),
...Object.values(composeStories(RefreshStories, preview)),
...Object.values(composeStories(PositionStories, preview)),
...Object.values(composeStories(EventStories, preview)),
...Object.values(composeStories(StateStories, preview)),
];
for (const Story of stories) {
render(<Story />);
for (const button of screen.queryAllByRole("button"))
fireEvent.click(button);
for (const link of screen.queryAllByRole("link")) fireEvent.click(link);
for (const image of screen.queryAllByRole("img")) {
expect(image).not.toHaveAttribute("src");
}
cleanup();
}
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
});
it("keeps synthetic totals visible while the refresh example calls a local retry", () => {
const { RefreshError } = composeStories(RefreshStories, preview);
const retry = fn();
const view = render(<RefreshError retry={retry} />);
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
fireEvent.click(screen.getByRole("button"));
expect(retry).toHaveBeenCalledOnce();
view.rerender(
<RefreshError refreshing refreshError={false} retry={retry} />,
);
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
expect(screen.getByRole("status")).toBeVisible();
});
});