feat: add Storybook for WealthLedger components
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
storybook-static
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const base = require("../dependency-cruiser.cjs");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...base,
|
||||||
|
forbidden: [
|
||||||
|
...base.forbidden,
|
||||||
|
{
|
||||||
|
name: "stories-must-not-reach-application-data",
|
||||||
|
severity: "error",
|
||||||
|
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
|
||||||
|
to: {
|
||||||
|
path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/[^/]+Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: { ...base.options, tsPreCompilationDeps: false },
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/**/*.stories.tsx"],
|
||||||
|
addons: ["@storybook/addon-docs"],
|
||||||
|
framework: "@storybook/react-vite",
|
||||||
|
core: { disableTelemetry: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
|
import "dayjs/locale/ru";
|
||||||
|
import "../src/app/styles.css";
|
||||||
|
import "../src/shared/i18n";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import type { Preview } from "@storybook/react-vite";
|
||||||
|
import { theme } from "../src/app/theme";
|
||||||
|
|
||||||
|
const colorSchemeManager = {
|
||||||
|
get: () => "light" as const,
|
||||||
|
set: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
subscribe: () => {},
|
||||||
|
unsubscribe: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
controls: { expanded: true },
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<MantineProvider
|
||||||
|
theme={theme}
|
||||||
|
forceColorScheme="light"
|
||||||
|
colorSchemeManager={colorSchemeManager}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</MantineProvider>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
+18
-1
@@ -7,6 +7,7 @@
|
|||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!dist",
|
"!dist",
|
||||||
|
"!storybook-static",
|
||||||
"!src/shared/api/generated",
|
"!src/shared/api/generated",
|
||||||
"!src/shared/api/spec",
|
"!src/shared/api/spec",
|
||||||
"!coverage",
|
"!coverage",
|
||||||
@@ -32,7 +33,12 @@
|
|||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"includes": ["vite.config.ts", "vitest.config.ts"],
|
"includes": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
".storybook/main.ts",
|
||||||
|
".storybook/preview.tsx"
|
||||||
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
@@ -40,6 +46,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/*.stories.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off",
|
||||||
|
"useExportsLast": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ Biome-enforced guardrails:
|
|||||||
- Не использовать default exports.
|
- Не использовать default exports.
|
||||||
- Export statements держать после declarations тела module.
|
- Export statements держать после declarations тела module.
|
||||||
|
|
||||||
|
Storybook CSF-файлы `*.stories.tsx` используют штатный `export default meta`
|
||||||
|
и прямые `export const` для stories: статический indexer должен видеть их
|
||||||
|
метаданные и названия. Biome разрешает эти два исключения только для stories;
|
||||||
|
`.storybook/main.ts` и `preview.tsx` разрешают только обязательный default export.
|
||||||
|
Synthetic fixtures и Storybook descriptions являются данными/документацией
|
||||||
|
истории; продуктовые UI-строки по-прежнему принадлежат i18n.
|
||||||
|
|
||||||
Mandatory review conventions:
|
Mandatory review conventions:
|
||||||
|
|
||||||
- React components типизировать через `FC`: `const Component: FC<Props> = (props) => { ... }`; если props нет — `const Component: FC = () => { ... }`.
|
- React components типизировать через `FC`: `const Component: FC<Props> = (props) => { ... }`; если props нет — `const Component: FC = () => { ... }`.
|
||||||
|
|||||||
Generated
+2190
-11
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -8,6 +8,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:catalog": "storybook dev -p 6006 --no-open --disable-telemetry",
|
||||||
|
"build:catalog": "storybook build --output-dir storybook-static --disable-telemetry",
|
||||||
|
"preview:catalog": "vite preview --outDir storybook-static",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -18,9 +21,10 @@
|
|||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build",
|
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"deps:storybook": "depcruise src .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{css,html,json,md,ts,tsx}": "biome format --write"
|
"*.{css,html,json,md,ts,tsx}": "biome format --write"
|
||||||
@@ -47,6 +51,8 @@
|
|||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.2",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.2",
|
||||||
"@rolldown/plugin-babel": "0.2.3",
|
"@rolldown/plugin-babel": "0.2.3",
|
||||||
|
"@storybook/addon-docs": "10.6.0",
|
||||||
|
"@storybook/react-vite": "10.6.0",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "16.3.0",
|
"@testing-library/react": "16.3.0",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
@@ -62,6 +68,7 @@
|
|||||||
"lint-staged": "16.2.7",
|
"lint-staged": "16.2.7",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
|
"storybook": "10.6.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "8.2.0",
|
"vite": "8.2.0",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
"Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-6
@@ -5,25 +5,23 @@
|
|||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["src/*"] },
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", ".storybook/preview.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", ".storybook/main.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -12,7 +12,11 @@ export default defineConfig({
|
|||||||
coverage: {
|
coverage: {
|
||||||
all: true,
|
all: true,
|
||||||
include: ["src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
exclude: ["src/test/**", "src/**/*.test.{ts,tsx}"],
|
exclude: [
|
||||||
|
"src/test/**",
|
||||||
|
"src/**/*.test.{ts,tsx}",
|
||||||
|
"src/**/*.stories.tsx",
|
||||||
|
],
|
||||||
reporter: ["text", "html"],
|
reporter: ["text", "html"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 82,
|
statements: 82,
|
||||||
|
|||||||
Reference in New Issue
Block a user