feat: add all-accounts portfolio pages
Reuse account sections and group securities with per-account holdings. Use Mantine Select to preserve filters and reset pagination on scope changes. Document component reuse guardrails and cover routes, grouping and scope switching.
This commit is contained in:
@@ -124,3 +124,24 @@
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.accountPositions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--mantine-spacing-sm) 0 0;
|
||||
}
|
||||
|
||||
.accountPosition {
|
||||
padding-block: var(--mantine-spacing-sm);
|
||||
border-top: 1px solid var(--mantine-color-gray-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.accountPosition:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.accountPositionName {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
||||
|
||||
const AccountPage: FC = () => {
|
||||
const viewModel = useAccountPageViewModel();
|
||||
type AccountPageProps = { accountId?: string };
|
||||
|
||||
const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
const viewModel = useAccountPageViewModel(props.accountId);
|
||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||
if (viewModel.state === "empty")
|
||||
return (
|
||||
<AccountPageEmpty
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
refreshing={viewModel.refreshing}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
@@ -24,6 +27,7 @@ const AccountPage: FC = () => {
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
refreshing={viewModel.refreshing}
|
||||
|
||||
@@ -172,13 +172,13 @@ describe("useAccountPageViewModel", () => {
|
||||
"bonds",
|
||||
"other",
|
||||
]);
|
||||
expect(sections[2].positions.map((position) => position.figi)).toEqual([
|
||||
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
|
||||
"FIGI-FUND",
|
||||
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
|
||||
]);
|
||||
expect(sections[0].positions[0].shareText).toBe("40 %");
|
||||
expect(sections[1].positions[0].shareText).toBe("20 %");
|
||||
expect(sections[2].positions[1].shareText).toBe("20 %");
|
||||
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
|
||||
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
|
||||
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
|
||||
expect(snapshot).toEqual(original);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,12 @@ describe("useAccountPageViewModel", () => {
|
||||
typeof result.current,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
expect(loaded.positionSections).toMatchObject([
|
||||
expect(
|
||||
loaded.positionSections.map(({ kind, groups }) => ({
|
||||
kind,
|
||||
positions: groups.flatMap((group) => group.positions),
|
||||
})),
|
||||
).toMatchObject([
|
||||
{
|
||||
kind: "stocks",
|
||||
positions: [
|
||||
|
||||
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
logoSrc: string | null;
|
||||
};
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
key: string;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
kind: PositionSectionKind;
|
||||
positions: AccountPagePosition[];
|
||||
groups: AccountPagePositionGroup[];
|
||||
};
|
||||
|
||||
type AccountPageRecentOperations =
|
||||
@@ -76,8 +81,9 @@ type AccountPageViewModel =
|
||||
| AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel;
|
||||
|
||||
function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const portfolio = usePortfolio(accountId);
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
limit: 5,
|
||||
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
|
||||
const stocks = toSection("stocks", portfolio, denominators);
|
||||
const bonds = toSection("bonds", portfolio, denominators);
|
||||
const other = toSection("other", portfolio, denominators);
|
||||
return [stocks, bonds, other].filter(
|
||||
(section) => section.positions.length > 0,
|
||||
);
|
||||
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
|
||||
}
|
||||
|
||||
function toSection(
|
||||
@@ -189,20 +193,51 @@ function toSection(
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
kind,
|
||||
positions: portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
if (kind === "bonds") return position.type === "BOND";
|
||||
return (
|
||||
position.type !== "STOCK" &&
|
||||
position.type !== "BOND" &&
|
||||
position.type !== "CURRENCY"
|
||||
);
|
||||
})
|
||||
.map((position) => toPosition(position, denominators, kind)),
|
||||
groups: groupPositions(
|
||||
portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
if (kind === "bonds") return position.type === "BOND";
|
||||
return (
|
||||
position.type !== "STOCK" &&
|
||||
position.type !== "BOND" &&
|
||||
position.type !== "CURRENCY"
|
||||
);
|
||||
})
|
||||
.map((position) => toPosition(position, denominators, kind)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupPositions(
|
||||
positions: AccountPagePosition[],
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
for (const [index, position] of positions.entries()) {
|
||||
const identity = position.figi?.trim()
|
||||
? ["figi", position.figi.trim()]
|
||||
: position.isin?.trim()
|
||||
? ["isin", position.isin.trim()]
|
||||
: null;
|
||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||
const key =
|
||||
position.account && identity
|
||||
? JSON.stringify([position.type, ...identity])
|
||||
: rowKey;
|
||||
const group = groups.get(key);
|
||||
if (
|
||||
group &&
|
||||
!group.positions.some((item) => item.account?.id === position.account?.id)
|
||||
) {
|
||||
group.positions.push(position);
|
||||
} else {
|
||||
const uniqueKey = group ? rowKey : key;
|
||||
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
@@ -237,6 +272,7 @@ function toPosition(
|
||||
export {
|
||||
type AccountPageIncomeSummary,
|
||||
type AccountPagePosition,
|
||||
type AccountPagePositionGroup,
|
||||
type AccountPagePositionSection,
|
||||
type AccountPageRecentEvents,
|
||||
type AccountPageRecentOperations,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account";
|
||||
|
||||
type AccountHeadingProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||
@@ -14,8 +15,16 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
||||
{account ? (
|
||||
<Title order={1}>
|
||||
{props.accountId === "all"
|
||||
? t("accounts.all")
|
||||
: (account?.name ?? t("portfolio.title"))}
|
||||
</Title>
|
||||
{props.accountId === "all" ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.allDescription")}
|
||||
</Text>
|
||||
) : account ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(accountTypeKey(account.type))}
|
||||
</Text>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
@@ -28,7 +29,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} />
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
refreshError={props.refreshError ?? false}
|
||||
@@ -36,7 +37,11 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
/>
|
||||
<StateView
|
||||
title={t("portfolio.emptyTitle")}
|
||||
text={t("portfolio.emptyText")}
|
||||
text={t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allEmptyText"
|
||||
: "portfolio.emptyText",
|
||||
)}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
@@ -35,7 +36,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<AccountHeading account={props.account} />
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
refreshError={props.refreshError ?? false}
|
||||
@@ -45,9 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
||||
) : null}
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.updatedAt", {
|
||||
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
||||
})}
|
||||
{t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allUpdatedAt"
|
||||
: "portfolio.updatedAt",
|
||||
{
|
||||
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
<Accordion
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type {
|
||||
@@ -37,6 +38,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const accountLink = position.account ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
size="xs"
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
) : null;
|
||||
const secondaryText = [
|
||||
position.averagePurchasePrice
|
||||
? t("portfolio.avgPrice", {
|
||||
@@ -63,6 +73,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
{accountLink}
|
||||
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||
{identifierText}
|
||||
</Text>
|
||||
@@ -94,6 +105,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
{accountLink}
|
||||
{position.isin ? (
|
||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||
{position.isin}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type {
|
||||
AccountPagePositionGroup,
|
||||
AccountPagePositionSection,
|
||||
} from "../AccountPage.vm";
|
||||
import { PositionLogo } from "./PositionLogo.view";
|
||||
|
||||
type PositionGroupCardProps = {
|
||||
group: AccountPagePositionGroup;
|
||||
kind: AccountPagePositionSection["kind"];
|
||||
};
|
||||
|
||||
const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
const { group, kind } = props;
|
||||
const { t } = useTranslation();
|
||||
const identity = group.positions[0];
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
{kind !== "other" ? (
|
||||
<PositionLogo
|
||||
key={identity.logoSrc ?? "no-logo"}
|
||||
src={identity.logoSrc}
|
||||
label={identity.displayName}
|
||||
/>
|
||||
) : null}
|
||||
<Stack gap={2} miw={0}>
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{identity.ticker ?? identity.isin ?? identity.figi}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack
|
||||
component="ul"
|
||||
gap={0}
|
||||
className={classes.accountPositions}
|
||||
aria-label={t("portfolio.byAccount")}
|
||||
>
|
||||
{group.positions.map((position) => (
|
||||
<Stack
|
||||
component="li"
|
||||
key={position.account?.id}
|
||||
gap={4}
|
||||
className={classes.accountPosition}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="xs">
|
||||
{position.account ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
size="sm"
|
||||
className={classes.accountPositionName}
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
) : null}
|
||||
<Text size="sm" fw={600}>
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" gap="xs">
|
||||
{position.currentValue ? (
|
||||
<Text size="sm">{formatMoney(position.currentValue)}</Text>
|
||||
) : null}
|
||||
{position.returnText ? (
|
||||
<Text
|
||||
size="sm"
|
||||
className={
|
||||
position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{kind === "other"
|
||||
? t("portfolio.return", { value: position.returnText })
|
||||
: position.returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{position.averagePurchasePrice ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{position.shareText ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export { PositionGroupCard };
|
||||
@@ -2,6 +2,7 @@ import { SimpleGrid } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||
import { PositionCard } from "./PositionCard.view";
|
||||
import { PositionGroupCard } from "./PositionGroupCard.view";
|
||||
|
||||
type PositionSectionProps = {
|
||||
section: AccountPagePositionSection;
|
||||
@@ -13,13 +14,21 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
{section.positions.map((position) => (
|
||||
<PositionCard
|
||||
kind={section.kind}
|
||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||
position={position}
|
||||
/>
|
||||
))}
|
||||
{section.groups.map((group) =>
|
||||
group.positions.length > 1 ? (
|
||||
<PositionGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
kind={section.kind}
|
||||
/>
|
||||
) : (
|
||||
<PositionCard
|
||||
key={group.key}
|
||||
position={group.positions[0]}
|
||||
kind={section.kind}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,14 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
) : null}
|
||||
{operations.state === "loaded"
|
||||
? operations.items.map((operation) => (
|
||||
<LedgerEntryRow key={operation.id} operation={operation} />
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<Button component={Link} to={operations.to} variant="subtle">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
const position: Portfolio["positions"][number] = {
|
||||
account: { id: "a", name: "Основной" },
|
||||
figi: "FIGI-SBER",
|
||||
displayName: "Сбербанк",
|
||||
type: "STOCK",
|
||||
quantity: "1.25",
|
||||
currentValue: { currency: "RUB", value: "375" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "280" },
|
||||
absoluteReturn: { currency: "RUB", value: "25" },
|
||||
isin: "RU0009029540",
|
||||
ticker: "SBER",
|
||||
logoName: null,
|
||||
};
|
||||
const second = {
|
||||
...position,
|
||||
account: { id: "b", name: "ИИС" },
|
||||
quantity: "-2",
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
};
|
||||
function sections(positions: Portfolio["positions"]) {
|
||||
return toAccountPagePositionSections({
|
||||
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
cashBalances: [],
|
||||
positions,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Positions grouped by security", () => {
|
||||
it("shows one security with exact per-account quantities and valuations", () => {
|
||||
const section = sections([position, second])[0];
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<PositionSection section={section} />
|
||||
</MemoryRouter>
|
||||
</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: "Основной" }),
|
||||
).toHaveAttribute("href", "/accounts/a");
|
||||
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
|
||||
expect(
|
||||
within(rows[0]).getByText("Средняя цена: 280 ₽"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByRole("link", { name: "ИИС" })).toHaveAttribute(
|
||||
"href",
|
||||
"/accounts/b",
|
||||
);
|
||||
expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument();
|
||||
expect(rows[1]).not.toHaveTextContent("₽");
|
||||
});
|
||||
|
||||
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, figi: null },
|
||||
{ ...second, figi: null },
|
||||
])[0].groups,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, figi: null, isin: null },
|
||||
{ ...second, figi: null, isin: null },
|
||||
])[0].groups,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
|
||||
expect(
|
||||
sections([position, { ...second, account: position.account }])[0].groups,
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, account: undefined },
|
||||
{ ...second, account: undefined },
|
||||
])[0].groups,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps currencies and purchase prices attached to their source positions", () => {
|
||||
const source = [
|
||||
position,
|
||||
{
|
||||
...second,
|
||||
currentValue: { currency: "USD", value: "1.01" },
|
||||
averagePurchasePrice: { currency: "USD", value: "0.99" },
|
||||
},
|
||||
];
|
||||
const before = structuredClone(source);
|
||||
const [group] = sections(source)[0].groups;
|
||||
expect(
|
||||
group.positions.map((item) => [
|
||||
item.quantity,
|
||||
item.currentValue,
|
||||
item.averagePurchasePrice,
|
||||
]),
|
||||
).toEqual(
|
||||
source.map((item) => [
|
||||
item.quantity,
|
||||
item.currentValue,
|
||||
item.averagePurchasePrice,
|
||||
]),
|
||||
);
|
||||
expect(source).toEqual(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user