fix: resolve frontend audit findings #39
+10
-7
@@ -22,13 +22,7 @@
|
|||||||
!packages/ui/vite.config.ts
|
!packages/ui/vite.config.ts
|
||||||
!packages/ui/tsconfig.json
|
!packages/ui/tsconfig.json
|
||||||
!packages/ui/src/
|
!packages/ui/src/
|
||||||
!packages/ui/src/theme.ts
|
!packages/ui/src/**
|
||||||
!packages/ui/src/styles.css
|
|
||||||
!packages/ui/src/index.ts
|
|
||||||
!packages/ui/src/StateView.tsx
|
|
||||||
!packages/ui/src/LoadingSkeleton.tsx
|
|
||||||
!packages/ui/src/EntryCard.tsx
|
|
||||||
!packages/ui/src/EntryCard.module.css
|
|
||||||
!packages/icons/
|
!packages/icons/
|
||||||
!packages/icons/package.json
|
!packages/icons/package.json
|
||||||
!packages/icons/tsconfig.json
|
!packages/icons/tsconfig.json
|
||||||
@@ -40,3 +34,12 @@
|
|||||||
!packages/icons/src/svg/AddIcon.tsx
|
!packages/icons/src/svg/AddIcon.tsx
|
||||||
!packages/icons/src/svg/EditIcon.tsx
|
!packages/icons/src/svg/EditIcon.tsx
|
||||||
!packages/icons/src/svg/DeleteIcon.tsx
|
!packages/icons/src/svg/DeleteIcon.tsx
|
||||||
|
|
||||||
|
# Keep local dependencies, outputs and secrets out of allowed source trees.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/coverage
|
||||||
|
**/storybook-static
|
||||||
|
**/.env*
|
||||||
|
**/*.pem
|
||||||
|
**/*.key
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
forbidden: [
|
forbidden: [
|
||||||
|
{
|
||||||
|
name: "no-unresolved",
|
||||||
|
severity: "error",
|
||||||
|
from: {},
|
||||||
|
to: { couldNotResolve: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "packages-must-not-depend-on-app",
|
name: "packages-must-not-depend-on-app",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
@@ -57,7 +63,11 @@ module.exports = {
|
|||||||
options: {
|
options: {
|
||||||
exclude: "^packages/[^/]+/dist/",
|
exclude: "^packages/[^/]+/dist/",
|
||||||
doNotFollow: { path: "node_modules" },
|
doNotFollow: { path: "node_modules" },
|
||||||
tsConfig: { fileName: "tsconfig.app.json" },
|
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
|
||||||
|
enhancedResolveOptions: {
|
||||||
|
exportsFields: ["exports"],
|
||||||
|
conditionNames: ["import", "require", "node", "default"],
|
||||||
|
},
|
||||||
tsPreCompilationDeps: true,
|
tsPreCompilationDeps: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "npm run build:packages && knip",
|
"knip": "npm run build:packages && knip",
|
||||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run build:packages && 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",
|
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
"muted": {
|
"muted": {
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
"shade": 6,
|
"shade": 7,
|
||||||
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
||||||
},
|
},
|
||||||
"border": {
|
"border": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest";
|
|||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
|
|
||||||
describe("EntryCard", () => {
|
describe("EntryCard", () => {
|
||||||
it("keeps the fallback after an image fails", () => {
|
it("keeps a failed image fallback until a new source is supplied", () => {
|
||||||
const props = {
|
const props = {
|
||||||
title: "Пример",
|
title: "Пример",
|
||||||
metadata: "Описание",
|
metadata: "Описание",
|
||||||
@@ -31,5 +31,15 @@ describe("EntryCard", () => {
|
|||||||
expect(container.querySelector("img")).toBeNull();
|
expect(container.querySelector("img")).toBeNull();
|
||||||
expect(screen.getByText("У")).toBeInTheDocument();
|
expect(screen.getByText("У")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<EntryCard {...props} imageSrc="/replacement-logo.png" />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("img")).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/replacement-logo.png",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("У")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { type FC, type ReactNode, useState } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import classes from "./EntryCard.module.css";
|
import classes from "./EntryCard.module.css";
|
||||||
|
|
||||||
type EntryCardProps = {
|
type EntryCardProps = {
|
||||||
@@ -17,7 +17,6 @@ type EntryCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
className={classes.row}
|
className={classes.row}
|
||||||
@@ -35,8 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
radius="md"
|
radius="md"
|
||||||
size={40}
|
size={40}
|
||||||
src={imageFailed ? null : props.imageSrc}
|
src={props.imageSrc}
|
||||||
onError={() => setImageFailed(true)}
|
|
||||||
>
|
>
|
||||||
{props.icon ?? props.initial}
|
{props.icon ?? props.initial}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -25,5 +25,8 @@ describe("design theme adapter", () => {
|
|||||||
expect(Object.keys(variables)).toHaveLength(
|
expect(Object.keys(variables)).toHaveLength(
|
||||||
Object.keys(source.color.roles).length,
|
Object.keys(source.color.roles).length,
|
||||||
);
|
);
|
||||||
|
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
|
||||||
|
variables["--wl-color-muted"],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
|
|||||||
getThemeColor(reference, resolvedTheme),
|
getThemeColor(reference, resolvedTheme),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
light: {},
|
light: {
|
||||||
|
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
|
||||||
|
},
|
||||||
dark: {},
|
dark: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import type { FC } from "react";
|
import { type FC, lazy, Suspense } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||||
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("RouteErrorBoundary", () => {
|
describe("RouteErrorBoundary", () => {
|
||||||
|
it.each(["/accounts", "/login"])(
|
||||||
|
"recovers a transient failure through the fallback on %s itself",
|
||||||
|
async (path) => {
|
||||||
|
authenticated = path === "/accounts";
|
||||||
|
let failing = true;
|
||||||
|
const RecoverableRoute: FC = () => {
|
||||||
|
if (failing) throw new Error("temporary failure");
|
||||||
|
return <div>recovered route</div>;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<RouteErrorBoundary>
|
||||||
|
<RecoverableRoute />
|
||||||
|
</RouteErrorBoundary>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const action = screen.getByRole("link", { name: "Продолжить" });
|
||||||
|
failing = false;
|
||||||
|
fireEvent.click(action);
|
||||||
|
expect(await screen.findByText("recovered route")).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
|
||||||
|
authenticated = true;
|
||||||
|
renderBoundary("/accounts");
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Перезагрузить страницу" }),
|
||||||
|
).toHaveAttribute("href", "/accounts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
|
||||||
|
authenticated = false;
|
||||||
|
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
|
||||||
|
const Chunk = lazy(load);
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<RouteErrorBoundary>
|
||||||
|
<Suspense fallback={<div>loading chunk</div>}>
|
||||||
|
<Chunk />
|
||||||
|
</Suspense>
|
||||||
|
</RouteErrorBoundary>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
|
||||||
|
expect(reload).toHaveAttribute("href", "/login");
|
||||||
|
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
|
||||||
|
let intercepted = true;
|
||||||
|
const preventNavigation = (event: MouseEvent) => {
|
||||||
|
intercepted = event.defaultPrevented;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
document.addEventListener("click", preventNavigation, { once: true });
|
||||||
|
fireEvent.click(reload);
|
||||||
|
expect(intercepted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("offers login after a public route error without error details", () => {
|
it("offers login after a public route error without error details", () => {
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
renderBoundary("/login");
|
renderBoundary("/login");
|
||||||
|
|||||||
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
|||||||
<Button component={Link} to={to}>
|
<Button component={Link} to={to}>
|
||||||
{t("routeError.action")}
|
{t("routeError.action")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button component="a" href={to} variant="default">
|
||||||
|
{t("routeError.reload")}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search, key } = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
|
<Boundary
|
||||||
|
authenticated={Boolean(user)}
|
||||||
|
routeKey={`${pathname}${search}:${key}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Boundary>
|
</Boundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
|
|||||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||||
}
|
}
|
||||||
function bankMonth(value: string | null) {
|
function bankMonth(value: string | null) {
|
||||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
||||||
? value
|
? value
|
||||||
: bankToday().slice(0, ISO_MONTH_LENGTH);
|
: bankToday().slice(0, ISO_MONTH_LENGTH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { bankPeriod } from "./period";
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["0001-01", "0001-01-01", "0001-01-31"],
|
||||||
|
["0099-12", "0099-12-01", "0099-12-31"],
|
||||||
|
["2026-09", "2026-09-01", "2026-09-30"],
|
||||||
|
["2024-02", "2024-02-01", "2024-02-29"],
|
||||||
|
["0004-02", "0004-02-01", "0004-02-29"],
|
||||||
|
])("keeps the ISO year for legacy month %s", (month, from, to) => {
|
||||||
|
expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero year consistently in legacy and explicit periods", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
|
||||||
|
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
|
||||||
|
expect(bankPeriod(new URLSearchParams(query))).toEqual({
|
||||||
|
from: "2026-09-01",
|
||||||
|
to: "2026-09-30",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { bankMonth } from "./form-validation";
|
import { bankMonth } from "./form-validation";
|
||||||
|
|
||||||
const calendarDate = z.string().date();
|
const calendarDate = z.string().date();
|
||||||
|
const ISO_DATE_LENGTH = 10;
|
||||||
function bankPeriod(params: URLSearchParams) {
|
function bankPeriod(params: URLSearchParams) {
|
||||||
const month = bankMonth(params.get("month"));
|
const month = bankMonth(params.get("month"));
|
||||||
|
const monthEnd = new Date(`${month}-01T00:00:00Z`);
|
||||||
|
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
|
||||||
const from = params.get("from");
|
const from = params.get("from");
|
||||||
const to = params.get("to");
|
const to = params.get("to");
|
||||||
return from &&
|
return from &&
|
||||||
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
|
|||||||
? { from, to }
|
? { from, to }
|
||||||
: {
|
: {
|
||||||
from: `${month}-01`,
|
from: `${month}-01`,
|
||||||
to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"),
|
to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
|
|||||||
import { CategoryManager } from "./CategoryManager";
|
import { CategoryManager } from "./CategoryManager";
|
||||||
|
|
||||||
describe("CategoryManager", () => {
|
describe("CategoryManager", () => {
|
||||||
|
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test",
|
||||||
|
refresh: async () => false,
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
const categories = [
|
||||||
|
{ id: "a", name: "Первая", isDefault: false, version: 1 },
|
||||||
|
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
|
||||||
|
];
|
||||||
|
let reads = 0;
|
||||||
|
let release = () => {};
|
||||||
|
const reloaded = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const patches: { id: unknown; body: unknown }[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/bank-cards/categories", async () => {
|
||||||
|
reads++;
|
||||||
|
if (reads === 3) await reloaded;
|
||||||
|
return HttpResponse.json(
|
||||||
|
categories.map((category) =>
|
||||||
|
category.id === "a" && reads > 1
|
||||||
|
? {
|
||||||
|
...category,
|
||||||
|
name: reads >= 3 ? "Первая обновлена" : category.name,
|
||||||
|
version: 2,
|
||||||
|
}
|
||||||
|
: category,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.patch(
|
||||||
|
"*/api/v1/bank-cards/categories/:id",
|
||||||
|
async ({ request, params }) => {
|
||||||
|
patches.push({ id: params.id, body: await request.json() });
|
||||||
|
return patches.length === 1
|
||||||
|
? HttpResponse.json(
|
||||||
|
{ code: "BANK_VERSION_CONFLICT" },
|
||||||
|
{ status: 409 },
|
||||||
|
)
|
||||||
|
: HttpResponse.json(categories[1]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<CategoryManager onClose={() => {}} />
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(reads).toBe(3));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("button", { name: "Переименовать" })[1],
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||||
|
target: { value: "Новая вторая" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
release();
|
||||||
|
await reloaded;
|
||||||
|
});
|
||||||
|
await screen.findByText("Первая обновлена");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: /Использовать актуальную версию/,
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(/Название категории/)).toHaveValue(
|
||||||
|
"Новая вторая",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{ id: "a", body: { name: "Первая", version: 1 } },
|
||||||
|
{ id: "b", body: { name: "Новая вторая", version: 4 } },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Отмена", "Сохранить"])(
|
||||||
|
"clears reloaded recovery after %s",
|
||||||
|
async (action) => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test",
|
||||||
|
refresh: async () => false,
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
let version = 1;
|
||||||
|
let writes = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/bank-cards/categories", () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ id: "a", name: "Первая", isDefault: false, version },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
http.patch("*/api/v1/bank-cards/categories/a", () => {
|
||||||
|
version = 2;
|
||||||
|
return ++writes === 1
|
||||||
|
? HttpResponse.json(
|
||||||
|
{ code: "BANK_VERSION_CONFLICT" },
|
||||||
|
{ status: 409 },
|
||||||
|
)
|
||||||
|
: HttpResponse.json({});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<CategoryManager onClose={() => {}} />
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Переименовать" }),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Использовать актуальную версию/,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: action }));
|
||||||
|
await screen.findByRole("button", { name: "Добавить категорию" });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: /Использовать актуальную версию/,
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
|
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "test",
|
accessToken: () => "test",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
const mutation = useBankMutations();
|
const mutation = useBankMutations();
|
||||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||||
|
const editSession = useRef(0);
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||||
const methods = useForm<CategoryValues>({
|
const methods = useForm<CategoryValues>({
|
||||||
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
});
|
});
|
||||||
if (result) props.onCreated?.(result.id);
|
if (result) props.onCreated?.(result.id);
|
||||||
}
|
}
|
||||||
|
editSession.current++;
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
methods.reset({ name: "" });
|
methods.reset({ name: "" });
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure);
|
setError(failure);
|
||||||
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
editSession.current++;
|
||||||
setEditing(category);
|
setEditing(category);
|
||||||
methods.reset({ name: category.name });
|
methods.reset({ name: category.name });
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const session = editSession.current;
|
||||||
const response = await categories.refetch();
|
const response = await categories.refetch();
|
||||||
|
if (session !== editSession.current) return;
|
||||||
if (response.error) setError(response.error);
|
if (response.error) setError(response.error);
|
||||||
else
|
else
|
||||||
setLatest(
|
setLatest(
|
||||||
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{latest && (
|
{latest && latest.id === editing?.id && (
|
||||||
<>
|
<>
|
||||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
editSession.current++;
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
methods.reset({ name: "" });
|
methods.reset({ name: "" });
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { useStockQuote } from "@/entities/stock";
|
||||||
|
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useScreenSync } from "../index";
|
import { useScreenSync } from "../index";
|
||||||
|
import { commitTbankConnectionBoundary } from "./connection-boundary";
|
||||||
|
|
||||||
function auth() {
|
function auth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
@@ -16,6 +18,207 @@ function auth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
describe("page-owned screen synchronization", () => {
|
describe("page-owned screen synchronization", () => {
|
||||||
|
it.each([false, true])(
|
||||||
|
"retries a failed GET without forcing the source (cached=%s)",
|
||||||
|
async (cached) => {
|
||||||
|
auth();
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
let unavailable = !cached;
|
||||||
|
let posts = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return unavailable
|
||||||
|
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(
|
||||||
|
resource([], { stateVersion: String(modes.length) }),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.post("*/api/v1/tbank-sync", () => {
|
||||||
|
posts += 1;
|
||||||
|
return new HttpResponse(null, { status: 500 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
const disabled = useStockQuote("");
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
disabled,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.accounts.isSuccess).toBe(true),
|
||||||
|
);
|
||||||
|
unavailable = true;
|
||||||
|
await act(() => result.current.accounts.refetch());
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
||||||
|
unavailable = false;
|
||||||
|
await act(() => result.current.sync.retry());
|
||||||
|
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
|
||||||
|
expect(result.current.accounts.meta?.stateVersion).toBe(
|
||||||
|
cached ? "3" : "2",
|
||||||
|
);
|
||||||
|
expect(modes).toEqual(
|
||||||
|
cached ? [null, null, "observe"] : [null, "observe"],
|
||||||
|
);
|
||||||
|
expect(posts).toBe(0);
|
||||||
|
expect(result.current.disabled.data).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
|
||||||
|
).toHaveLength(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([
|
||||||
|
["logout", true],
|
||||||
|
["connection", true],
|
||||||
|
["session", false],
|
||||||
|
["connection", false],
|
||||||
|
["access", false],
|
||||||
|
] as const)(
|
||||||
|
"discards recovery after %s changes (HTTP already pending=%s)",
|
||||||
|
async (boundary, pending) => {
|
||||||
|
auth();
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let recovering = false;
|
||||||
|
let changed = false;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
|
||||||
|
if (boundary === "connection" && changed && !request.signal.aborted)
|
||||||
|
return HttpResponse.json(resource([], { stateVersion: "current" }));
|
||||||
|
if (!new URL(request.url).searchParams.has("sync"))
|
||||||
|
return changed
|
||||||
|
? HttpResponse.json(resource([], { stateVersion: "current" }))
|
||||||
|
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
|
||||||
|
recovering = true;
|
||||||
|
await blocked;
|
||||||
|
return HttpResponse.json(resource([], { stateVersion: "old" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
|
const published: unknown[] = [];
|
||||||
|
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||||
|
if (event.type === "updated" && event.action.type === "success")
|
||||||
|
published.push(event.query.state.data);
|
||||||
|
});
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
||||||
|
let recovery!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
recovery = result.current.sync.retry();
|
||||||
|
});
|
||||||
|
if (pending) await waitFor(() => expect(recovering).toBe(true));
|
||||||
|
await act(async () => {
|
||||||
|
changed = true;
|
||||||
|
if (boundary === "logout") {
|
||||||
|
configureAuthClient(null);
|
||||||
|
queryClient.clear();
|
||||||
|
} else if (boundary === "session") {
|
||||||
|
auth();
|
||||||
|
queryClient.clear();
|
||||||
|
} else if (boundary === "access") {
|
||||||
|
invalidateResourceAccess(queryClient, "brokerage", {
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
commitTbankConnectionBoundary(queryClient, {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: "2026-09-27T00:00:00Z",
|
||||||
|
});
|
||||||
|
release();
|
||||||
|
await recovery;
|
||||||
|
});
|
||||||
|
if (boundary === "connection")
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
|
||||||
|
);
|
||||||
|
expect(published).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
meta: expect.objectContaining({ stateVersion: "old" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unsubscribe();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([false, true])(
|
||||||
|
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
|
||||||
|
async (retryStatus) => {
|
||||||
|
auth();
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
let polls = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return modes.length === 1
|
||||||
|
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource([]));
|
||||||
|
}),
|
||||||
|
http.post("*/api/v1/tbank-sync", () =>
|
||||||
|
HttpResponse.json({ id: "recover" }, { status: 202 }),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () => {
|
||||||
|
polls += 1;
|
||||||
|
return retryStatus && polls === 1
|
||||||
|
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json({
|
||||||
|
id: "recover",
|
||||||
|
state: "succeeded",
|
||||||
|
resources: [resource([]).meta],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
const disabled = useStockQuote("");
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
||||||
|
expect(result.current.accounts.meta).toBeUndefined();
|
||||||
|
await act(() => result.current.sync.refresh());
|
||||||
|
if (retryStatus) {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.sync.transportError).toBe(true),
|
||||||
|
);
|
||||||
|
await act(() => result.current.sync.retry());
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
|
||||||
|
expect(result.current.accounts.data).toEqual([]);
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
expect(
|
||||||
|
queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
|
||||||
|
).toHaveLength(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
it("posts only its scope once and observes completion without importing again", async () => {
|
it("posts only its scope once and observes completion without importing again", async () => {
|
||||||
auth();
|
auth();
|
||||||
const modes: (string | null)[] = [];
|
const modes: (string | null)[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
|
|||||||
type ResourceMeta =
|
type ResourceMeta =
|
||||||
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||||
type ScreenResource = {
|
type ScreenResource = {
|
||||||
|
queryKey: QueryKey;
|
||||||
meta?: ResourceMeta;
|
meta?: ResourceMeta;
|
||||||
|
transport: {
|
||||||
|
status: "pending" | "success" | "error";
|
||||||
|
fetchStatus: "idle" | "fetching" | "paused";
|
||||||
|
};
|
||||||
observe: (version?: string) => Promise<unknown>;
|
observe: (version?: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
const STATUS_INTERVAL_MS = 1000;
|
const STATUS_INTERVAL_MS = 1000;
|
||||||
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
latest.set(dependency.resourceKey, dependency);
|
latest.set(dependency.resourceKey, dependency);
|
||||||
}
|
}
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
if (!resource.meta) continue;
|
if (!resource.meta) {
|
||||||
|
if (resource.transport.fetchStatus !== "idle") continue;
|
||||||
|
const completed = statuses.find(
|
||||||
|
(status) =>
|
||||||
|
requested?.scope === scopeKey &&
|
||||||
|
status.data?.id === requested.id &&
|
||||||
|
status.data.state !== "running",
|
||||||
|
)?.data;
|
||||||
|
const key = `query:${JSON.stringify(resource.queryKey)}`;
|
||||||
|
if (completed && observed.current.get(key) !== completed.id) {
|
||||||
|
observed.current.set(key, completed.id);
|
||||||
|
void resource.observe().catch(() => undefined);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const changed = [
|
const changed = [
|
||||||
resource.meta,
|
resource.meta,
|
||||||
...(resource.meta.dependencies ?? []),
|
...(resource.meta.dependencies ?? []),
|
||||||
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
);
|
);
|
||||||
void resource.observe(own?.stateVersion).catch(() => undefined);
|
void resource.observe(own?.stateVersion).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}, [client, statuses, resources]);
|
}, [client, statuses, resources, requested, scopeKey]);
|
||||||
const refreshing =
|
const refreshing =
|
||||||
(currentMutation && mutation.isPending) ||
|
(currentMutation && mutation.isPending) ||
|
||||||
statuses.some(
|
statuses.some(
|
||||||
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
|
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Retry failed HTTP reads without asking the source to import again.
|
||||||
|
// A source failure (or explicit refresh with no read error) still needs a sync.
|
||||||
|
const retry = async () => {
|
||||||
|
const failed = resources.filter(
|
||||||
|
(resource) => resource.transport.status === "error",
|
||||||
|
);
|
||||||
|
const failedStatuses = statuses.filter((status) => status.isError);
|
||||||
|
if (failed.length || failedStatuses.length) {
|
||||||
|
await Promise.allSettled([
|
||||||
|
...failed.map((resource) => resource.observe()),
|
||||||
|
...failedStatuses.map((status) => status.refetch()),
|
||||||
|
]);
|
||||||
|
} else await refresh();
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
refresh,
|
refresh,
|
||||||
|
retry,
|
||||||
refreshing,
|
refreshing,
|
||||||
transportError:
|
transportError:
|
||||||
(currentMutation && mutation.isError) ||
|
(currentMutation && mutation.isError) ||
|
||||||
|
|||||||
+119
-7
@@ -1,11 +1,18 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
||||||
import { TbankConnectionSection } from "./TbankConnectionSection";
|
import { TbankConnectionSection } from "./TbankConnectionSection";
|
||||||
|
|
||||||
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
||||||
@@ -78,6 +85,74 @@ function seededClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("TbankConnectionSection", () => {
|
describe("TbankConnectionSection", () => {
|
||||||
|
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
|
||||||
|
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
|
||||||
|
let finishRefetch: (() => void) | undefined;
|
||||||
|
const response = new Promise<void>((resolve) => {
|
||||||
|
finishRefetch = resolve;
|
||||||
|
});
|
||||||
|
let reads = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/tbank-connection", async () => {
|
||||||
|
reads += 1;
|
||||||
|
if (reads === 1) {
|
||||||
|
await response;
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HttpResponse.json(status);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = seededClient();
|
||||||
|
client.setQueryData(tbankConnectionQueryOptions().queryKey, status);
|
||||||
|
renderSection(client);
|
||||||
|
await waitFor(() => expect(reads).toBe(1));
|
||||||
|
const token = screen.getByLabelText("Token T-Bank");
|
||||||
|
fireEvent.change(token, { target: { value: "replacement-draft" } });
|
||||||
|
finishRefetch?.();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Не удалось обновить статус подключения/),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
|
||||||
|
expect(token).toHaveValue("replacement-draft");
|
||||||
|
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
|
||||||
|
expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual(
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
expect(client.getQueryData(portfolioQueryKey)).toEqual({
|
||||||
|
source: "previous-connection",
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(reads).toBe(2));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Не удалось обновить статус подключения/),
|
||||||
|
).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(token).toHaveValue("replacement-draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a cold-start error without a form and recovers by retrying status", async () => {
|
||||||
|
let reads = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/tbank-connection", () => {
|
||||||
|
reads += 1;
|
||||||
|
return reads === 1
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json({ connected: false, connectedAt: null });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderSection(createTestQueryClient());
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Не удалось загрузить статус подключения/),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
|
||||||
|
expect(reads).toBe(2);
|
||||||
|
});
|
||||||
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
|
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
|
||||||
const client = seededClient();
|
const client = seededClient();
|
||||||
renderSection(client);
|
renderSection(client);
|
||||||
@@ -225,7 +300,12 @@ describe("TbankConnectionSection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves connected status and financial cache when deletion fails", async () => {
|
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
|
||||||
|
let deletes = 0;
|
||||||
|
let finishDelete: (() => void) | undefined;
|
||||||
|
const response = new Promise<void>((resolve) => {
|
||||||
|
finishDelete = resolve;
|
||||||
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/tbank-connection", () =>
|
http.get("*/api/v1/tbank-connection", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
|
|||||||
connectedAt: "2026-08-14T00:00:00Z",
|
connectedAt: "2026-08-14T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
http.delete("*/api/v1/tbank-connection", () =>
|
http.delete("*/api/v1/tbank-connection", async () => {
|
||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
deletes += 1;
|
||||||
),
|
if (deletes === 1) await response;
|
||||||
|
return deletes === 1
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const client = seededClient();
|
const client = seededClient();
|
||||||
renderSection(client);
|
renderSection(client);
|
||||||
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
|
|||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: "Удалить подключение" }),
|
await screen.findByRole("button", { name: "Удалить подключение" }),
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
|
const dialog = await screen.findByRole("dialog", {
|
||||||
|
name: "Удалить подключение?",
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true),
|
||||||
|
);
|
||||||
|
const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ });
|
||||||
|
confirm.focus();
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() => expect(confirm).toBeDisabled());
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Закрыть" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
// JSDOM does not move focus when a focused button becomes disabled.
|
||||||
|
document.body.tabIndex = -1;
|
||||||
|
document.body.focus();
|
||||||
|
document.body.removeAttribute("tabindex");
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
finishDelete?.();
|
||||||
|
|
||||||
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз.");
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||||
|
"Не удалось выполнить запрос. Попробуйте ещё раз.",
|
||||||
|
);
|
||||||
|
expect(confirm).toHaveFocus();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Заменить token" }),
|
screen.getByRole("button", { name: "Заменить token" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
|
|||||||
expect(client.getQueryData(portfolioQueryKey)).toEqual({
|
expect(client.getQueryData(portfolioQueryKey)).toEqual({
|
||||||
source: "previous-connection",
|
source: "previous-connection",
|
||||||
});
|
});
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Не подключено")).toBeVisible();
|
||||||
|
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
|
||||||
|
expect(deletes).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TbankConnectionSection: FC = () => {
|
|||||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
||||||
if (viewModel.state === "error")
|
if (viewModel.state === "error")
|
||||||
return <TbankConnectionError retry={viewModel.retry} />;
|
return <TbankConnectionError retry={viewModel.retry} />;
|
||||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
return <TbankConnectionSectionLoaded {...viewModel} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { TbankConnectionSection };
|
export { TbankConnectionSection };
|
||||||
|
|||||||
+9
-2
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
|
|||||||
const status = useAuthenticatedQuery({
|
const status = useAuthenticatedQuery({
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
});
|
});
|
||||||
|
if (!status.data) {
|
||||||
if (status.isPending) return { state: "loading" as const };
|
if (status.isPending) return { state: "loading" as const };
|
||||||
if (status.isError)
|
|
||||||
return { state: "error" as const, retry: () => status.refetch() };
|
return { state: "error" as const, retry: () => status.refetch() };
|
||||||
return { state: "loaded" as const, status: status.data };
|
}
|
||||||
|
return {
|
||||||
|
state: "loaded" as const,
|
||||||
|
status: status.data,
|
||||||
|
refreshing: status.isFetching,
|
||||||
|
refreshError: status.isError,
|
||||||
|
retry: () => status.refetch(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useTbankConnectionViewModel };
|
export { useTbankConnectionViewModel };
|
||||||
|
|||||||
+36
-3
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
|
|
||||||
type TbankConnectionSectionLoadedProps = {
|
type TbankConnectionSectionLoadedProps = {
|
||||||
status: TbankConnectionStatus;
|
status: TbankConnectionStatus;
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshError: boolean;
|
||||||
|
retry: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||||
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||||
|
const deleteButton = useRef<HTMLButtonElement>(null);
|
||||||
const [requestError, setRequestError] = useState<string | null>(null);
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
});
|
});
|
||||||
setDeleteOpened(false);
|
setDeleteOpened(false);
|
||||||
},
|
},
|
||||||
onError: () => setRequestError("REQUEST_ERROR"),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const pending = update.isPending || remove.isPending;
|
const pending = update.isPending || remove.isPending;
|
||||||
const connected = status.connected;
|
const connected = status.connected;
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteOpened && remove.isError) deleteButton.current?.focus();
|
||||||
|
}, [deleteOpened, remove.isError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack mt="xl">
|
<Stack mt="xl">
|
||||||
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("tbank.hint")}
|
{t("tbank.hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
{props.refreshing && (
|
||||||
|
<Text role="status" size="sm" c="dimmed">
|
||||||
|
{t("tbank.refreshing")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{props.refreshError && (
|
||||||
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text>{t("tbank.refreshError")}</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={props.retry}
|
||||||
|
disabled={props.refreshing || pending}
|
||||||
|
>
|
||||||
|
{t("tbank.retry")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(({ token }) => {
|
onSubmit={handleSubmit(({ token }) => {
|
||||||
setRequestError(null);
|
setRequestError(null);
|
||||||
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => setDeleteOpened(true)}
|
onClick={() => {
|
||||||
|
remove.reset();
|
||||||
|
setDeleteOpened(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("tbank.delete")}
|
{t("tbank.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
title={t("tbank.deleteTitle")}
|
title={t("tbank.deleteTitle")}
|
||||||
closeOnClickOutside={!pending}
|
closeOnClickOutside={!pending}
|
||||||
closeOnEscape={!pending}
|
closeOnEscape={!pending}
|
||||||
|
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{t("tbank.deleteText")}</Text>
|
<Text>{t("tbank.deleteText")}</Text>
|
||||||
|
{remove.isError && (
|
||||||
|
<Alert color="red">{t("tbank.requestError")}</Alert>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
ref={deleteButton}
|
||||||
color="red"
|
color="red"
|
||||||
loading={remove.isPending}
|
loading={remove.isPending}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
renderHook,
|
renderHook,
|
||||||
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
|
|||||||
<button type="button" onClick={() => navigate(-1)}>
|
<button type="button" onClick={() => navigate(-1)}>
|
||||||
History back
|
History back
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate(1)}>
|
||||||
|
History forward
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Upcoming event date filters", () => {
|
describe("Upcoming event date filters", () => {
|
||||||
|
it("keeps reads and sync disabled without an account scope", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const requests: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.all("*/api/v1/*", ({ request }) => {
|
||||||
|
requests.push(request.url);
|
||||||
|
return new HttpResponse(null, { status: 503 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryWrapper>
|
||||||
|
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryWrapper>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
expect(result.current).toEqual({ state: "loading" });
|
||||||
|
expect(queryClient.isFetching()).toBe(0);
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"from=2099-02-01&to=2099-02-28",
|
||||||
|
"from=2099-02-01",
|
||||||
|
"to=2099-02-28",
|
||||||
|
])(
|
||||||
|
"keeps event calendar dates in GET and source sync requests: %s",
|
||||||
|
async (search) => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const requested: string[] = [];
|
||||||
|
const syncRequests: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
|
||||||
|
requested.push(new URL(request.url).search);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(null, {
|
||||||
|
sync: {
|
||||||
|
id: null,
|
||||||
|
state: "failed",
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
syncRequests.push(await request.json());
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const period = Object.fromEntries(new URLSearchParams(search));
|
||||||
|
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useAccountEventsPageViewModel("all"),
|
||||||
|
{
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryWrapper>
|
||||||
|
<MemoryRouter initialEntries={[`?${search}`]}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryWrapper>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.state).toBe("error"));
|
||||||
|
expect(requested).toEqual([`?${search}`]);
|
||||||
|
expect(syncRequests).toEqual([]);
|
||||||
|
act(() => {
|
||||||
|
if (result.current.state === "error") result.current.retry();
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(syncRequests).toEqual([
|
||||||
|
{ screen: "events", accountId: "all", ...period },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["account-1", "all"])(
|
||||||
|
"normalizes invalid links and history while preserving one-sided future dates for %s",
|
||||||
|
async (accountId) => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const searches: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||||
|
({ request }) => {
|
||||||
|
searches.push(new URL(request.url).search);
|
||||||
|
return HttpResponse.json(resource({ events: [] }));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter
|
||||||
|
initialIndex={0}
|
||||||
|
initialEntries={[
|
||||||
|
"?from=bad&to=2099-02-28",
|
||||||
|
"?from=2099-02-30",
|
||||||
|
"?from=2099-03-01&to=2099-02-28",
|
||||||
|
"?from=2099-02-01",
|
||||||
|
"?to=2099-02-28",
|
||||||
|
].map((search) => `/accounts/${accountId}/events${search}`)}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/events"
|
||||||
|
element={<AccountEventsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "History forward" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("URL").textContent).toBe(expected),
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
}
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
||||||
|
"?from=2099-02-01",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
|
||||||
|
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["account-1", "all"])(
|
||||||
|
"lets users reset filters after a request failure for %s",
|
||||||
|
async (accountId) => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||||
|
({ request }) =>
|
||||||
|
new URL(request.url).searchParams.has("from")
|
||||||
|
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
|
||||||
|
: HttpResponse.json(resource({ events: [] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/events"
|
||||||
|
element={<AccountEventsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
expect(screen.getByLabelText("URL").textContent).toBe("");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(["account-1", "all"])(
|
it.each(["account-1", "all"])(
|
||||||
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
|
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
|
||||||
async (accountId) => {
|
async (accountId) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
|
|||||||
vi.mock("@/features/tbank-connection", () => ({
|
vi.mock("@/features/tbank-connection", () => ({
|
||||||
useScreenSync: () => ({
|
useScreenSync: () => ({
|
||||||
refresh,
|
refresh,
|
||||||
|
retry: refresh,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
sourceError: false,
|
sourceError: false,
|
||||||
transportError: false,
|
transportError: false,
|
||||||
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
|
|||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
} as never);
|
} as never);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(query).toHaveBeenCalledWith(undefined, {});
|
expect(query).toHaveBeenCalledWith("", {});
|
||||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||||
"aria-busy",
|
"aria-busy",
|
||||||
"true",
|
"true",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
|||||||
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
||||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||||
if (viewModel.state === "error")
|
if (viewModel.state === "error")
|
||||||
return <AccountEventsPageError retry={viewModel.retry} />;
|
return <AccountEventsPageError {...viewModel} />;
|
||||||
return <AccountEventsPageLoaded {...viewModel} />;
|
return <AccountEventsPageLoaded {...viewModel} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
|
|||||||
|
|
||||||
type AccountEventsPageViewModel =
|
type AccountEventsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void; onReset: () => void }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
events: BrokerageEvent[];
|
events: BrokerageEvent[];
|
||||||
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
|
|||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
): AccountEventsPageViewModel {
|
): AccountEventsPageViewModel {
|
||||||
const { accountId: routeAccountId } = useParams();
|
const { accountId: routeAccountId } = useParams();
|
||||||
const accountId = scopeId ?? routeAccountId;
|
const accountId = scopeId ?? routeAccountId ?? "";
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const appliedFrom = searchParams.get("from");
|
const urlFrom = searchParams.get("from");
|
||||||
const appliedTo = searchParams.get("to");
|
const urlTo = searchParams.get("to");
|
||||||
|
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||||
|
const appliedFrom = validUrlPeriod ? urlFrom : null;
|
||||||
|
const appliedTo = validUrlPeriod ? urlTo : null;
|
||||||
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
|
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
|
||||||
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
|
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
|
||||||
|
useEffect(() => {
|
||||||
|
if (validUrlPeriod) return;
|
||||||
|
const normalized = new URLSearchParams(searchParams);
|
||||||
|
normalized.delete("from");
|
||||||
|
normalized.delete("to");
|
||||||
|
setSearchParams(normalized, { replace: true });
|
||||||
|
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftFrom(appliedFrom);
|
setDraftFrom(appliedFrom);
|
||||||
setDraftTo(appliedTo);
|
setDraftTo(appliedTo);
|
||||||
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
|
|||||||
},
|
},
|
||||||
[events],
|
[events],
|
||||||
);
|
);
|
||||||
|
const onReset = () => {
|
||||||
|
setDraftFrom(null);
|
||||||
|
setDraftTo(null);
|
||||||
|
setSearchParams({});
|
||||||
|
};
|
||||||
if (events.isLoading) return { state: "loading" };
|
if (events.isLoading) return { state: "loading" };
|
||||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||||
return {
|
return {
|
||||||
state: "error",
|
state: "error",
|
||||||
|
onReset,
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void sync.refresh();
|
void sync.retry();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (!events.data) return { state: "loading" };
|
if (!events.data) return { state: "loading" };
|
||||||
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
|
|||||||
refreshing: events.isFetching || sync.refreshing,
|
refreshing: events.isFetching || sync.refreshing,
|
||||||
refreshError: events.isError || sync.sourceError || sync.transportError,
|
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void sync.refresh();
|
void sync.retry();
|
||||||
},
|
},
|
||||||
draftFrom,
|
draftFrom,
|
||||||
draftTo,
|
draftTo,
|
||||||
canApply:
|
canApply:
|
||||||
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
|
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
|
||||||
(!draftFrom || !draftTo || draftFrom <= draftTo),
|
isValidPeriod(draftFrom, draftTo),
|
||||||
onDraftFromChange: setDraftFrom,
|
onDraftFromChange: setDraftFrom,
|
||||||
onDraftToChange: setDraftTo,
|
onDraftToChange: setDraftTo,
|
||||||
onApply: () =>
|
onApply: () => {
|
||||||
|
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||||
setSearchParams({
|
setSearchParams({
|
||||||
...(draftFrom ? { from: draftFrom } : {}),
|
...(draftFrom ? { from: draftFrom } : {}),
|
||||||
...(draftTo ? { to: draftTo } : {}),
|
...(draftTo ? { to: draftTo } : {}),
|
||||||
}),
|
});
|
||||||
onReset: () => {
|
|
||||||
setDraftFrom(null);
|
|
||||||
setDraftTo(null);
|
|
||||||
setSearchParams({});
|
|
||||||
},
|
},
|
||||||
|
onReset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calendarDate = z.string().date();
|
||||||
|
function isValidPeriod(from: string | null, to: string | null) {
|
||||||
|
return (
|
||||||
|
[from, to].every(
|
||||||
|
(date) => date === null || calendarDate.safeParse(date).success,
|
||||||
|
) &&
|
||||||
|
(!from || !to || from <= to)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
||||||
|
|||||||
+10
-3
@@ -1,9 +1,9 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type AccountEventsPageErrorProps = { retry: () => void };
|
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
|
||||||
|
|
||||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
|||||||
error
|
error
|
||||||
title={t("events.errorTitle")}
|
title={t("events.errorTitle")}
|
||||||
text={t("events.errorText")}
|
text={t("events.errorText")}
|
||||||
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
|
action={
|
||||||
|
<Group>
|
||||||
|
<Button onClick={props.retry}>{t("events.retry")}</Button>
|
||||||
|
<Button variant="default" onClick={props.onReset}>
|
||||||
|
{t("ledger.reset")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
act,
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
|
renderHook,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
within,
|
within,
|
||||||
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { isDate } from "../../model/period";
|
import { isDate } from "../../model/period";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
import {
|
||||||
|
toIncomeDisplayModel,
|
||||||
|
useAccountIncomePageViewModel,
|
||||||
|
} from "./AccountIncomePage.vm";
|
||||||
import { IncomePayments } from "./components/IncomePayments.view";
|
import { IncomePayments } from "./components/IncomePayments.view";
|
||||||
|
|
||||||
const analytics = {
|
const analytics = {
|
||||||
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountIncomePage", () => {
|
describe("AccountIncomePage", () => {
|
||||||
|
it("keeps reads and sync disabled without an account scope", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const requests: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.all("*/api/v1/*", ({ request }) => {
|
||||||
|
requests.push(request.url);
|
||||||
|
return new HttpResponse(null, { status: 503 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryWrapper>
|
||||||
|
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryWrapper>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
expect(result.current).toEqual({ state: "loading" });
|
||||||
|
expect(queryClient.isFetching()).toBe(0);
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps income calendar dates in GET and source sync requests", async () => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
const syncRequests: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||||
|
({ request }) => {
|
||||||
|
requested.push(new URL(request.url).search);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(null, {
|
||||||
|
sync: {
|
||||||
|
id: null,
|
||||||
|
state: "failed",
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
syncRequests.push(await request.json());
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderPage("from=2024-02-29&to=2024-03-01");
|
||||||
|
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
|
||||||
|
expect(syncRequests).toEqual([]);
|
||||||
|
fireEvent.click(retry);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(syncRequests).toEqual([
|
||||||
|
{
|
||||||
|
screen: "income",
|
||||||
|
accountId: "account-1",
|
||||||
|
from: "2024-02-29",
|
||||||
|
to: "2024-03-01",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("requests a multi-year period from the URL without resetting it", async () => {
|
it("requests a multi-year period from the URL without resetting it", async () => {
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||||
server.use(
|
const dependencies = [
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
{ ...resource(analytics).meta, role: "selected" as const },
|
||||||
HttpResponse.json(
|
{ ...resource(analytics).meta, role: "previous" as const },
|
||||||
resource(analytics, {
|
|
||||||
dependencies: [
|
|
||||||
resource(analytics).meta,
|
|
||||||
resource(analytics).meta,
|
|
||||||
{
|
{
|
||||||
...resource(null).meta,
|
...resource(null).meta,
|
||||||
|
role: "year" as const,
|
||||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
}),
|
server.use(
|
||||||
),
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(resource(analytics, { dependencies })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
it("maps comparison notices by role regardless of dependency order or resource key", () => {
|
||||||
|
const selected = { ...resource(analytics).meta, role: "selected" as const };
|
||||||
|
const previous = {
|
||||||
|
...resource(null).meta,
|
||||||
|
resourceKey: "opaque/year-looking-key",
|
||||||
|
role: "previous" as const,
|
||||||
|
sync: {
|
||||||
|
id: null,
|
||||||
|
state: "failed" as const,
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const year = {
|
||||||
|
...resource(null).meta,
|
||||||
|
resourceKey: "opaque/previous-looking-key",
|
||||||
|
role: "year" as const,
|
||||||
|
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||||
|
};
|
||||||
|
for (const dependencies of [
|
||||||
|
[selected, previous, year],
|
||||||
|
[year, selected, previous],
|
||||||
|
]) {
|
||||||
|
const display = toIncomeDisplayModel(analytics, {
|
||||||
|
...resource(analytics).meta,
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
expect(display?.previousNotice).toBe("comparisonFailed");
|
||||||
|
expect(display?.yearNotice).toBe("yearUnavailable");
|
||||||
|
expect(display?.previousComparison).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
|
||||||
|
async (legacy) => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource({ ...analytics, previous: null, yearComparison: null }),
|
resource(
|
||||||
|
{ ...analytics, previous: null, yearComparison: null },
|
||||||
|
{
|
||||||
|
dependencies: legacy
|
||||||
|
? [
|
||||||
|
resource(null).meta,
|
||||||
|
resource(null).meta,
|
||||||
|
resource(null).meta,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Сравнение с годом ранее недоступно."),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Период сравнения ещё не загружен полностью."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
||||||
|
const dependencies = (["year", "selected", "previous"] as const).map(
|
||||||
|
(role) => ({
|
||||||
|
...resource(role === "selected" ? analytics : null).meta,
|
||||||
|
role,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
|
{ ...analytics, previous: null, yearComparison: null },
|
||||||
|
{ dependencies },
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
|
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
|
||||||
server.use(
|
const dependencies = [
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
{ ...resource(analytics).meta, role: "previous" as const },
|
||||||
HttpResponse.json(
|
|
||||||
resource(analytics, {
|
|
||||||
dependencies: [
|
|
||||||
resource(analytics).meta,
|
|
||||||
resource(analytics).meta,
|
|
||||||
{
|
{
|
||||||
...resource(null).meta,
|
...resource(null).meta,
|
||||||
|
role: "year" as const,
|
||||||
sync: {
|
sync: {
|
||||||
id: null,
|
id: null,
|
||||||
state: "failed",
|
state: "failed" as const,
|
||||||
errorCode: "TBANK_UNAVAILABLE",
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
{ ...resource(analytics).meta, role: "selected" as const },
|
||||||
}),
|
];
|
||||||
),
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(resource(analytics, { dependencies })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
|||||||
type ComparisonNotice =
|
type ComparisonNotice =
|
||||||
| "comparisonPending"
|
| "comparisonPending"
|
||||||
| "comparisonFailed"
|
| "comparisonFailed"
|
||||||
|
| "previousUnavailable"
|
||||||
| "yearUnavailable";
|
| "yearUnavailable";
|
||||||
|
|
||||||
type IncomeDisplayModel = {
|
type IncomeDisplayModel = {
|
||||||
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
): AccountIncomePageViewModel => {
|
): AccountIncomePageViewModel => {
|
||||||
const { accountId: routeAccountId } = useParams();
|
const { accountId: routeAccountId } = useParams();
|
||||||
const accountId = scopeId ?? routeAccountId;
|
const accountId = scopeId ?? routeAccountId ?? "";
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requested = useMemo(
|
const requested = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
};
|
};
|
||||||
if (analytics.isLoading || !analytics.data) {
|
if (analytics.isLoading || !analytics.data) {
|
||||||
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
||||||
return { state: "error", retry: sync.refresh, period };
|
return { state: "error", retry: sync.retry, period };
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
}
|
}
|
||||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||||
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
partial: analytics.meta?.availability !== "complete",
|
partial: analytics.meta?.availability !== "complete",
|
||||||
refreshing: analytics.isFetching || sync.refreshing,
|
refreshing: analytics.isFetching || sync.refreshing,
|
||||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
|
|||||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The income endpoint returns selected, previous and year dependencies in order.
|
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
|
||||||
const [, previous, year] = meta?.dependencies ?? [];
|
const year = meta?.dependencies?.find(({ role }) => role === "year");
|
||||||
return {
|
return {
|
||||||
entries,
|
entries,
|
||||||
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||||
previousNotice: comparisonNotice(previous),
|
previousNotice: comparisonNotice(previous, "previousUnavailable"),
|
||||||
yearNotice: comparisonNotice(year),
|
yearNotice: comparisonNotice(year, "yearUnavailable"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +252,10 @@ function comparisonNotice(
|
|||||||
dependency:
|
dependency:
|
||||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||||
| undefined,
|
| undefined,
|
||||||
|
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||||
): ComparisonNotice {
|
): ComparisonNotice {
|
||||||
if (dependency?.unavailableReason) return "yearUnavailable";
|
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||||
return dependency?.sync.state === "failed"
|
return dependency.sync.state === "failed"
|
||||||
? "comparisonFailed"
|
? "comparisonFailed"
|
||||||
: "comparisonPending";
|
: "comparisonPending";
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-1
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import type { FC } from "react";
|
||||||
|
import {
|
||||||
|
MemoryRouter,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
} from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
|
|||||||
commission: { currency: "RUB", value: "1" },
|
commission: { currency: "RUB", value: "1" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Navigation: FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<output aria-label="URL">{location.search}</output>
|
||||||
|
<button type="button" onClick={() => navigate(-1)}>
|
||||||
|
History back
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate(1)}>
|
||||||
|
History forward
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function configureTestAuth() {
|
function configureTestAuth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountOperationsPage", () => {
|
describe("AccountOperationsPage", () => {
|
||||||
|
it.each(["account-1", "all"])(
|
||||||
|
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
|
||||||
|
async (accountId) => {
|
||||||
|
configureTestAuth();
|
||||||
|
const searches: URLSearchParams[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ request }) => {
|
||||||
|
searches.push(new URL(request.url).searchParams);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
|
items: [operation],
|
||||||
|
nextCursor: "next",
|
||||||
|
hasNext: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter
|
||||||
|
initialIndex={0}
|
||||||
|
initialEntries={[
|
||||||
|
"?from=bad&to=2026-02-28&categories=trade",
|
||||||
|
"?from=2026-02-30&categories=trade",
|
||||||
|
"?from=2026-03-01&to=2026-02-28&categories=trade",
|
||||||
|
"?from=2026-02-01&categories=trade",
|
||||||
|
"?to=2026-02-28&categories=trade",
|
||||||
|
].map((search) => `/accounts/${accountId}/operations${search}`)}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/operations"
|
||||||
|
element={<AccountOperationsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
expect(searches[0].get("from")).toBeNull();
|
||||||
|
expect(searches[0].get("to")).toBeNull();
|
||||||
|
for (const expected of [
|
||||||
|
"?categories=trade",
|
||||||
|
"?categories=trade",
|
||||||
|
"?from=2026-02-01&categories=trade",
|
||||||
|
]) {
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "History forward" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("URL").textContent).toBe(expected),
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
}
|
||||||
|
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
|
||||||
|
expect(searches.at(-1)?.get("to")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
|
||||||
|
);
|
||||||
|
expect(searches.at(-1)?.get("from")).toBeNull();
|
||||||
|
expect(searches.at(-1)?.get("cursor")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
|
expect(searches.map((query) => query.get("from"))).not.toContain(
|
||||||
|
"badT00:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
searches.every((query) => query.get("categories") === "trade"),
|
||||||
|
).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["account-1", "all"])(
|
||||||
|
"allows resetting filters after a request failure for %s",
|
||||||
|
async (accountId) => {
|
||||||
|
configureTestAuth();
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ request }) =>
|
||||||
|
new URL(request.url).searchParams.has("from")
|
||||||
|
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
|
||||||
|
: HttpResponse.json(
|
||||||
|
resource({ items: [], hasNext: false, nextCursor: null }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[
|
||||||
|
`/accounts/${accountId}/operations?from=2026-02-01`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/operations"
|
||||||
|
element={<AccountOperationsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||||
|
await screen.findByLabelText("Период");
|
||||||
|
expect(screen.getByLabelText("URL").textContent).toBe("");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("resets cursor pagination when filters change", async () => {
|
it("resets cursor pagination when filters change", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
const searches: string[] = [];
|
const searches: string[] = [];
|
||||||
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
const requested: Record<string, string>[] = [];
|
||||||
|
const syncRequests: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ request }) => {
|
||||||
|
requested.push(Object.fromEntries(new URL(request.url).searchParams));
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(null, {
|
||||||
|
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
syncRequests.push(await request.json());
|
||||||
|
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
|
||||||
|
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
expect(requested).toEqual([
|
||||||
|
{
|
||||||
|
from: "2024-02-29T00:00:00.000Z",
|
||||||
|
to: "2024-03-01T23:59:59.999Z",
|
||||||
|
categories: "trade",
|
||||||
|
limit: "100",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(syncRequests).toEqual([]);
|
||||||
|
fireEvent.click(retry);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(syncRequests).toEqual([
|
||||||
|
{
|
||||||
|
screen: "operations",
|
||||||
|
accountId: "account-1",
|
||||||
|
from: "2024-02-29T00:00:00.000Z",
|
||||||
|
to: "2024-03-01T23:59:59.999Z",
|
||||||
|
categories: ["trade"],
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
brokerageOperationsQueryOptions,
|
brokerageOperationsQueryOptions,
|
||||||
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
|
|||||||
|
|
||||||
type AccountOperationsPageViewModel =
|
type AccountOperationsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void; onReset: () => void }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
sourceError?: boolean;
|
sourceError?: boolean;
|
||||||
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
|
|||||||
const accountId = scopeId ?? routeAccountId;
|
const accountId = scopeId ?? routeAccountId;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const appliedFrom = searchParams.get("from");
|
const urlFrom = searchParams.get("from");
|
||||||
const appliedTo = searchParams.get("to");
|
const urlTo = searchParams.get("to");
|
||||||
|
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||||
|
const appliedFrom = validUrlPeriod ? urlFrom : null;
|
||||||
|
const appliedTo = validUrlPeriod ? urlTo : null;
|
||||||
const appliedCategories = operationCategories.filter((category) =>
|
const appliedCategories = operationCategories.filter((category) =>
|
||||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||||
);
|
);
|
||||||
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
|
|||||||
useState<OperationCategory[]>(appliedCategories);
|
useState<OperationCategory[]>(appliedCategories);
|
||||||
const scopeKey = JSON.stringify([
|
const scopeKey = JSON.stringify([
|
||||||
accountId,
|
accountId,
|
||||||
appliedFrom,
|
urlFrom,
|
||||||
appliedTo,
|
urlTo,
|
||||||
appliedCategories,
|
appliedCategories,
|
||||||
]);
|
]);
|
||||||
const [pagination, setPagination] = useState<{
|
const [pagination, setPagination] = useState<{
|
||||||
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
|
|||||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||||
const cursor = cursors.at(-1);
|
const cursor = cursors.at(-1);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftFrom(searchParams.get("from"));
|
setPagination((current) =>
|
||||||
setDraftTo(searchParams.get("to"));
|
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
|
||||||
|
);
|
||||||
|
}, [scopeKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (validUrlPeriod) return;
|
||||||
|
const normalized = new URLSearchParams(searchParams);
|
||||||
|
normalized.delete("from");
|
||||||
|
normalized.delete("to");
|
||||||
|
setSearchParams(normalized, { replace: true });
|
||||||
|
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftFrom(appliedFrom);
|
||||||
|
setDraftTo(appliedTo);
|
||||||
setDraftCategories(
|
setDraftCategories(
|
||||||
operationCategories.filter((category) =>
|
operationCategories.filter((category) =>
|
||||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [searchParams]);
|
}, [appliedFrom, appliedTo, searchParams]);
|
||||||
const firstPageQuery = {
|
const firstPageQuery = {
|
||||||
from: dateStart(appliedFrom),
|
from: dateStart(appliedFrom),
|
||||||
to: dateEnd(appliedTo),
|
to: dateEnd(appliedTo),
|
||||||
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
|
|||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
resetCursors();
|
resetCursors();
|
||||||
} else void sync.refresh();
|
} else void sync.retry();
|
||||||
|
};
|
||||||
|
const onReset = () => {
|
||||||
|
setDraftFrom(null);
|
||||||
|
setDraftTo(null);
|
||||||
|
setDraftCategories([]);
|
||||||
|
setSearchParams({});
|
||||||
|
resetCursors();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (operations.isLoading) return { state: "loading" };
|
if (operations.isLoading) return { state: "loading" };
|
||||||
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
|
|||||||
(!operations.data ||
|
(!operations.data ||
|
||||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||||
)
|
)
|
||||||
return { state: "error", retry };
|
return { state: "error", retry, onReset };
|
||||||
if (!operations.data) return { state: "loading" };
|
if (!operations.data) return { state: "loading" };
|
||||||
const nextCursor = operations.data.nextCursor;
|
const nextCursor = operations.data.nextCursor;
|
||||||
|
|
||||||
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
|
|||||||
items: operations.data.items,
|
items: operations.data.items,
|
||||||
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
||||||
hasPrevious: Boolean(cursors.length),
|
hasPrevious: Boolean(cursors.length),
|
||||||
canApply: isDirty,
|
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
|
||||||
onDraftFromChange: setDraftFrom,
|
onDraftFromChange: setDraftFrom,
|
||||||
onDraftToChange: setDraftTo,
|
onDraftToChange: setDraftTo,
|
||||||
onDraftCategoriesChange: setDraftCategories,
|
onDraftCategoriesChange: setDraftCategories,
|
||||||
onApply: () => {
|
onApply: () => {
|
||||||
|
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||||
setSearchParams({
|
setSearchParams({
|
||||||
...(draftFrom ? { from: draftFrom } : {}),
|
...(draftFrom ? { from: draftFrom } : {}),
|
||||||
...(draftTo ? { to: draftTo } : {}),
|
...(draftTo ? { to: draftTo } : {}),
|
||||||
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
|
|||||||
});
|
});
|
||||||
resetCursors();
|
resetCursors();
|
||||||
},
|
},
|
||||||
onReset: () => {
|
onReset,
|
||||||
setDraftFrom(null);
|
|
||||||
setDraftTo(null);
|
|
||||||
setDraftCategories([]);
|
|
||||||
setSearchParams({});
|
|
||||||
resetCursors();
|
|
||||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
|
||||||
},
|
|
||||||
onNext: () => {
|
onNext: () => {
|
||||||
if (nextCursor)
|
if (nextCursor)
|
||||||
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
|
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
|
||||||
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calendarDate = z.string().date();
|
||||||
|
function isValidPeriod(from: string | null, to: string | null) {
|
||||||
|
return (
|
||||||
|
[from, to].every(
|
||||||
|
(date) => date === null || calendarDate.safeParse(date).success,
|
||||||
|
) &&
|
||||||
|
(!from || !to || from <= to)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dateStart(value: string | null) {
|
function dateStart(value: string | null) {
|
||||||
return value ? `${value}T00:00:00.000Z` : undefined;
|
return value ? `${value}T00:00:00.000Z` : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
|||||||
error
|
error
|
||||||
title={t("ledger.errorTitle")}
|
title={t("ledger.errorTitle")}
|
||||||
text={t("ledger.errorText")}
|
text={t("ledger.errorText")}
|
||||||
action={<Button onClick={retry}>{t("ledger.retry")}</Button>}
|
action={
|
||||||
|
<Group>
|
||||||
|
<Button onClick={retry}>{t("ledger.retry")}</Button>
|
||||||
|
<Button variant="default" onClick={props.onReset}>
|
||||||
|
{t("ledger.reset")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: apiErrorCode(portfolio.error),
|
code: apiErrorCode(portfolio.error),
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
};
|
};
|
||||||
return { state: "loading" as const };
|
return { state: "loading" as const };
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
refreshing: portfolio.isFetching || sync.refreshing,
|
refreshing: portfolio.isFetching || sync.refreshing,
|
||||||
sourceError: sync.sourceError,
|
sourceError: sync.sourceError,
|
||||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
portfolio.meta?.availability === "complete" &&
|
portfolio.meta?.availability === "complete" &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountsPage", () => {
|
describe("AccountsPage", () => {
|
||||||
|
it("loads accounts after retrying an initial HTTP failure", async () => {
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return modes.length === 1
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource([account]));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderPage(createTestQueryClient());
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||||
|
expect(await screen.findByText(account.name)).toBeVisible();
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
});
|
||||||
it.each([
|
it.each([
|
||||||
["loading", AccountsPageLoading, "status"],
|
["loading", AccountsPageLoading, "status"],
|
||||||
["empty", AccountsPageEmpty, undefined],
|
["empty", AccountsPageEmpty, undefined],
|
||||||
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
|
|||||||
expect(screen.getByText(account.name)).toBeInTheDocument();
|
expect(screen.getByText(account.name)).toBeInTheDocument();
|
||||||
|
|
||||||
screen.getByRole("button", { name: "Повторить" }).click();
|
screen.getByRole("button", { name: "Повторить" }).click();
|
||||||
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
|
await waitFor(() => expect(requestCount).toBe(3));
|
||||||
expect(requestCount).toBe(2);
|
await waitFor(() => expect(warning).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText(account.name)).toBeVisible();
|
||||||
|
expect(requests).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a cached empty account list visible after a refresh error", async () => {
|
it("keeps a cached empty account list visible after a refresh error", async () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
|
|||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: apiErrorCode(accounts.error),
|
code: apiErrorCode(accounts.error),
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!accounts.data?.length)
|
if (!accounts.data?.length)
|
||||||
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
|
|||||||
refreshing: accounts.isFetching || sync.refreshing,
|
refreshing: accounts.isFetching || sync.refreshing,
|
||||||
sourceError: sync.sourceError,
|
sourceError: sync.sourceError,
|
||||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
|
|||||||
refreshing: accounts.isFetching || sync.refreshing,
|
refreshing: accounts.isFetching || sync.refreshing,
|
||||||
sourceError: sync.sourceError,
|
sourceError: sync.sourceError,
|
||||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||||
retry: sync.refresh,
|
retry: sync.retry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function useBondPageVm() {
|
|||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
} else void sync.refresh();
|
} else void sync.retry();
|
||||||
};
|
};
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
@@ -99,26 +99,26 @@ function useBondPageVm() {
|
|||||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||||
: null,
|
: null,
|
||||||
bond: bond.data,
|
bond: bond.data,
|
||||||
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
|
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
|
||||||
code: apiErrorCode(bond.error),
|
code: apiErrorCode(bond.error),
|
||||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||||
metrics: metrics.data,
|
metrics: metrics.data,
|
||||||
metricsState: section(metrics, sync.refresh),
|
metricsState: section(metrics, sync.retry),
|
||||||
quote: quote.data,
|
quote: quote.data,
|
||||||
quoteState: section(quote, sync.refresh),
|
quoteState: section(quote, sync.retry),
|
||||||
candles: candles.data,
|
candles: candles.data,
|
||||||
chartData,
|
chartData,
|
||||||
priceRows: chartData.toReversed(),
|
priceRows: chartData.toReversed(),
|
||||||
candlesState: section(candles, sync.refresh),
|
candlesState: section(candles, sync.retry),
|
||||||
period,
|
period,
|
||||||
setPeriod: (value: string) => setParam("period", value),
|
setPeriod: (value: string) => setParam("period", value),
|
||||||
averageLine: null,
|
averageLine: null,
|
||||||
hasIncompleteCandle:
|
hasIncompleteCandle:
|
||||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
positionState: section(position, sync.refresh),
|
positionState: section(position, sync.retry),
|
||||||
coupons: coupons.data,
|
coupons: coupons.data,
|
||||||
couponsState: section(coupons, sync.refresh),
|
couponsState: section(coupons, sync.retry),
|
||||||
upcoming:
|
upcoming:
|
||||||
coupons.data?.coupons
|
coupons.data?.coupons
|
||||||
.filter((event) => event.timing === "upcoming")
|
.filter((event) => event.timing === "upcoming")
|
||||||
@@ -137,7 +137,7 @@ function useBondPageVm() {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: {
|
operationsState: {
|
||||||
...section(operations, sync.refresh),
|
...section(operations, sync.retry),
|
||||||
retry: retryOperations,
|
retry: retryOperations,
|
||||||
},
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function useBondsPageVm() {
|
|||||||
partial: result.meta?.availability === "partial",
|
partial: result.meta?.availability === "partial",
|
||||||
code: apiErrorCode(result.error),
|
code: apiErrorCode(result.error),
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void sync.refresh();
|
void sync.retry();
|
||||||
},
|
},
|
||||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const operation = {
|
|||||||
};
|
};
|
||||||
function setup(
|
function setup(
|
||||||
options: {
|
options: {
|
||||||
|
month?: string;
|
||||||
missing?: boolean;
|
missing?: boolean;
|
||||||
summaryError?: boolean;
|
summaryError?: boolean;
|
||||||
cardsError?: boolean;
|
cardsError?: boolean;
|
||||||
@@ -101,7 +102,9 @@ function setup(
|
|||||||
render(
|
render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<MantineProvider env="test">
|
<MantineProvider env="test">
|
||||||
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
|
<MemoryRouter
|
||||||
|
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/cards/:cardId" element={<CardPage />} />
|
<Route path="/cards/:cardId" element={<CardPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -112,6 +115,26 @@ function setup(
|
|||||||
return { requests, summaryRequests, client };
|
return { requests, summaryRequests, client };
|
||||||
}
|
}
|
||||||
describe("CardPage", () => {
|
describe("CardPage", () => {
|
||||||
|
it.each([
|
||||||
|
["0001-01", "0001-01-31"],
|
||||||
|
["0099-12", "0099-12-31"],
|
||||||
|
["2026-09", "2026-09-30"],
|
||||||
|
["2024-02", "2024-02-29"],
|
||||||
|
])(
|
||||||
|
"sends the same legacy month %s range to history and summary",
|
||||||
|
async (month, to) => {
|
||||||
|
const { requests, summaryRequests } = setup({ month });
|
||||||
|
await screen.findByText("Перевод на «Запасная»");
|
||||||
|
await waitFor(() => expect(summaryRequests).toHaveLength(1));
|
||||||
|
for (const request of [requests[0], summaryRequests[0]]) {
|
||||||
|
const query = new URLSearchParams(request);
|
||||||
|
expect(query.get("from")).toBe(`${month}-01`);
|
||||||
|
expect(query.get("to")).toBe(to);
|
||||||
|
expect(query.has("month")).toBe(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("shares date range with history, pages the journal and keeps current balance", async () => {
|
it("shares date range with history, pages the journal and keeps current balance", async () => {
|
||||||
const { requests, summaryRequests } = setup();
|
const { requests, summaryRequests } = setup();
|
||||||
await screen.findByRole("heading", { name: "Повседневная" });
|
await screen.findByRole("heading", { name: "Повседневная" });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
|
|||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { StockPage } from "./StockPage";
|
import { StockPage } from "./StockPage";
|
||||||
|
import type { StockPageVm } from "./StockPage.vm";
|
||||||
|
|
||||||
const LocationProbe = () => {
|
const LocationProbe = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -72,6 +73,7 @@ function setup(
|
|||||||
path?: string;
|
path?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
ambiguousTicker?: boolean;
|
ambiguousTicker?: boolean;
|
||||||
|
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const requests: URL[] = [];
|
const requests: URL[] = [];
|
||||||
@@ -118,7 +120,7 @@ function setup(
|
|||||||
: HttpResponse.json(
|
: HttpResponse.json(
|
||||||
resource({
|
resource({
|
||||||
description: "Описание компании",
|
description: "Описание компании",
|
||||||
metrics: [
|
metrics: options.metrics ?? [
|
||||||
{
|
{
|
||||||
key: "peRatioTtm",
|
key: "peRatioTtm",
|
||||||
value: 5.6,
|
value: 5.6,
|
||||||
@@ -215,6 +217,63 @@ function setup(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
describe("stock page", () => {
|
describe("stock page", () => {
|
||||||
|
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
|
||||||
|
setup({
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
key: "marketCapitalization",
|
||||||
|
value: 1234567.89,
|
||||||
|
unit: "money",
|
||||||
|
currency: null,
|
||||||
|
period: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "revenueTtm",
|
||||||
|
value: 1234567.89,
|
||||||
|
unit: "money",
|
||||||
|
currency: "RUB",
|
||||||
|
period: "TTM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ebitdaTtm",
|
||||||
|
value: 1234567.89,
|
||||||
|
unit: "money",
|
||||||
|
currency: "USD",
|
||||||
|
period: "TTM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "netIncomeTtm",
|
||||||
|
value: 1234567.89,
|
||||||
|
unit: "money",
|
||||||
|
currency: "CAD",
|
||||||
|
period: "TTM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "peRatioTtm",
|
||||||
|
value: 5.678,
|
||||||
|
unit: "ratio",
|
||||||
|
currency: null,
|
||||||
|
period: "TTM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roe",
|
||||||
|
value: 12.3456,
|
||||||
|
unit: "percent",
|
||||||
|
currency: null,
|
||||||
|
period: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
|
||||||
|
for (const value of [
|
||||||
|
"1,23 млн ₽",
|
||||||
|
"1,23 млн USD",
|
||||||
|
"1,23 млн CAD",
|
||||||
|
"5,68 ×",
|
||||||
|
"12,35 %",
|
||||||
|
])
|
||||||
|
expect(screen.getByText(value)).toBeVisible();
|
||||||
|
});
|
||||||
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
|
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
|
||||||
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
|
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
|
||||||
await screen.findByText("Зачисление дивидендов");
|
await screen.findByText("Зачисление дивидендов");
|
||||||
@@ -290,6 +349,31 @@ describe("stock page", () => {
|
|||||||
),
|
),
|
||||||
).toHaveAttribute("aria-live", "polite");
|
).toHaveAttribute("aria-live", "polite");
|
||||||
});
|
});
|
||||||
|
it("recovers an initially missing company section without reloading successful sections", async () => {
|
||||||
|
const { requests } = setup({ failCompany: true });
|
||||||
|
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
await screen.findByText("Зачисление дивидендов");
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
|
description: "Восстановленное описание",
|
||||||
|
metrics: [],
|
||||||
|
unavailable: [],
|
||||||
|
observedAt: quote.observedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const previousRequests = requests.length;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
|
||||||
|
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
|
||||||
|
expect(requests).toHaveLength(previousRequests);
|
||||||
|
expect(modes).toEqual(["observe"]);
|
||||||
|
});
|
||||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
||||||
const { requests } = setup();
|
const { requests } = setup();
|
||||||
await screen.findByText("Зачисление дивидендов");
|
await screen.findByText("Зачисление дивидендов");
|
||||||
|
|||||||
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
metric.unit === "money" ? "compact" : "standard",
|
metric.unit === "money" ? "compact" : "standard",
|
||||||
}).format(metric.value)}{" "}
|
}).format(metric.value)}{" "}
|
||||||
{metric.unit === "money"
|
{metric.unit === "money"
|
||||||
? new Intl.NumberFormat("ru-RU", {
|
? metric.currency === "RUB"
|
||||||
style: "currency",
|
? "₽"
|
||||||
currency: metric.currency ?? "RUB",
|
: (metric.currency ?? t("stock.currencyUnknown"))
|
||||||
currencyDisplay: "narrowSymbol",
|
|
||||||
})
|
|
||||||
.formatToParts(0)
|
|
||||||
.find((part) => part.type === "currency")?.value
|
|
||||||
: metric.unit === "percent"
|
: metric.unit === "percent"
|
||||||
? "%"
|
? "%"
|
||||||
: "×"}
|
: "×"}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function useStockPageVm() {
|
|||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
} else void sync.refresh();
|
} else void sync.retry();
|
||||||
};
|
};
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
@@ -105,15 +105,15 @@ function useStockPageVm() {
|
|||||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||||
: null,
|
: null,
|
||||||
stock: stock.data,
|
stock: stock.data,
|
||||||
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
|
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
|
||||||
code: apiErrorCode(stock.error),
|
code: apiErrorCode(stock.error),
|
||||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||||
quote: quote.data,
|
quote: quote.data,
|
||||||
quoteState: section(quote, sync.refresh),
|
quoteState: section(quote, sync.retry),
|
||||||
candles: candles.data,
|
candles: candles.data,
|
||||||
chartData,
|
chartData,
|
||||||
priceRows: chartData.toReversed(),
|
priceRows: chartData.toReversed(),
|
||||||
candlesState: section(candles, sync.refresh),
|
candlesState: section(candles, sync.retry),
|
||||||
period,
|
period,
|
||||||
setPeriod: (value: string) => setParam("period", value),
|
setPeriod: (value: string) => setParam("period", value),
|
||||||
averageLine:
|
averageLine:
|
||||||
@@ -121,11 +121,11 @@ function useStockPageVm() {
|
|||||||
hasIncompleteCandle:
|
hasIncompleteCandle:
|
||||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
positionState: section(position, sync.refresh),
|
positionState: section(position, sync.retry),
|
||||||
company: companyData,
|
company: companyData,
|
||||||
companyState: section(company, sync.refresh),
|
companyState: section(company, sync.retry),
|
||||||
dividends: dividends.data,
|
dividends: dividends.data,
|
||||||
dividendsState: section(dividends, sync.refresh),
|
dividendsState: section(dividends, sync.retry),
|
||||||
upcoming:
|
upcoming:
|
||||||
dividends.data?.dividends
|
dividends.data?.dividends
|
||||||
.filter((event) => event.timing === "upcoming")
|
.filter((event) => event.timing === "upcoming")
|
||||||
@@ -144,7 +144,7 @@ function useStockPageVm() {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: {
|
operationsState: {
|
||||||
...section(operations, sync.refresh),
|
...section(operations, sync.retry),
|
||||||
retry: retryOperations,
|
retry: retryOperations,
|
||||||
},
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function useStocksPageVm() {
|
|||||||
partial: result.meta?.availability === "partial",
|
partial: result.meta?.availability === "partial",
|
||||||
code: apiErrorCode(result.error),
|
code: apiErrorCode(result.error),
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void sync.refresh();
|
void sync.retry();
|
||||||
},
|
},
|
||||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||||
items:
|
items:
|
||||||
|
|||||||
Vendored
+96
-15
@@ -1124,9 +1124,41 @@ export interface components {
|
|||||||
instruments: components["schemas"]["IncomeInstrumentDto"][];
|
instruments: components["schemas"]["IncomeInstrumentDto"][];
|
||||||
entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
|
entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
|
||||||
};
|
};
|
||||||
|
IncomeDependencyDto: {
|
||||||
|
dataRevision: string;
|
||||||
|
stateVersion: string;
|
||||||
|
unavailableReason?: string;
|
||||||
|
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||||
|
resourceKey: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
availability: "missing" | "partial" | "complete";
|
||||||
|
/** @enum {string} */
|
||||||
|
freshness: "unknown" | "fresh" | "stale";
|
||||||
|
/** Format: date-time */
|
||||||
|
lastSuccessfulAt: string | null;
|
||||||
|
sync: components["schemas"]["InvestmentSyncDto"];
|
||||||
|
/** @enum {string} */
|
||||||
|
role: "selected" | "previous" | "year";
|
||||||
|
};
|
||||||
|
IncomeAnalyticsMetaDto: {
|
||||||
|
dataRevision: string;
|
||||||
|
stateVersion: string;
|
||||||
|
unavailableReason?: string;
|
||||||
|
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||||
|
resourceKey: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
availability: "missing" | "partial" | "complete";
|
||||||
|
/** @enum {string} */
|
||||||
|
freshness: "unknown" | "fresh" | "stale";
|
||||||
|
/** Format: date-time */
|
||||||
|
lastSuccessfulAt: string | null;
|
||||||
|
sync: components["schemas"]["InvestmentSyncDto"];
|
||||||
|
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
|
||||||
|
dependencies: components["schemas"]["IncomeDependencyDto"][];
|
||||||
|
};
|
||||||
IncomeAnalyticsResourceDto: {
|
IncomeAnalyticsResourceDto: {
|
||||||
data: components["schemas"]["IncomeAnalyticsDto"] | null;
|
data: components["schemas"]["IncomeAnalyticsDto"] | null;
|
||||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
|
||||||
};
|
};
|
||||||
UpcomingEventDto: {
|
UpcomingEventDto: {
|
||||||
account?: components["schemas"]["AccountReferenceDto"];
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
@@ -1515,20 +1547,50 @@ export interface components {
|
|||||||
PutTbankConnectionDto: {
|
PutTbankConnectionDto: {
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
IncomeInvestmentScreenDto: {
|
||||||
|
accountId: string;
|
||||||
|
/**
|
||||||
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
screen: "income";
|
||||||
|
/**
|
||||||
|
* Format: date
|
||||||
|
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
|
||||||
|
*/
|
||||||
|
from?: string;
|
||||||
|
/**
|
||||||
|
* Format: date
|
||||||
|
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
|
||||||
|
*/
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
EventsInvestmentScreenDto: {
|
||||||
|
accountId: string;
|
||||||
|
/**
|
||||||
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
screen: "events";
|
||||||
|
/**
|
||||||
|
* Format: date
|
||||||
|
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
|
||||||
|
*/
|
||||||
|
from?: string;
|
||||||
|
/**
|
||||||
|
* Format: date
|
||||||
|
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
|
||||||
|
*/
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
SyncCandleRangeDto: {
|
SyncCandleRangeDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
from: string;
|
from: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
to: string;
|
to: string;
|
||||||
};
|
};
|
||||||
InvestmentScreenDto: {
|
TimestampInvestmentScreenDto: {
|
||||||
/** @enum {string} */
|
|
||||||
screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond";
|
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
/** Format: date-time */
|
|
||||||
from?: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
to?: string;
|
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -1537,11 +1599,26 @@ export interface components {
|
|||||||
candleRange?: components["schemas"]["SyncCandleRangeDto"];
|
candleRange?: components["schemas"]["SyncCandleRangeDto"];
|
||||||
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
|
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description Start instant for operations.
|
||||||
|
*/
|
||||||
|
from?: string;
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description End instant for operations.
|
||||||
|
*/
|
||||||
|
to?: string;
|
||||||
};
|
};
|
||||||
TbankSyncAcceptedDto: {
|
TbankSyncAcceptedDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
scope: components["schemas"]["InvestmentScreenDto"];
|
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
now: string;
|
now: string;
|
||||||
};
|
};
|
||||||
@@ -1553,7 +1630,7 @@ export interface components {
|
|||||||
TbankSyncStatusDto: {
|
TbankSyncStatusDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
scope: components["schemas"]["InvestmentScreenDto"] | null;
|
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
now: string;
|
now: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
@@ -3525,7 +3602,9 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
sync?: "observe";
|
sync?: "observe";
|
||||||
|
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
|
||||||
from?: string;
|
from?: string;
|
||||||
|
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -3661,7 +3740,9 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
sync?: "observe";
|
sync?: "observe";
|
||||||
|
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
|
||||||
from?: string;
|
from?: string;
|
||||||
|
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -3731,9 +3812,9 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
sync?: "observe";
|
sync?: "observe";
|
||||||
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
|
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
|
||||||
from?: string;
|
from?: string;
|
||||||
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
|
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -3801,9 +3882,9 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
sync?: "observe";
|
sync?: "observe";
|
||||||
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
|
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
|
||||||
from?: string;
|
from?: string;
|
||||||
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
|
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -5160,7 +5241,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["InvestmentScreenDto"];
|
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
|
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||||
import type { components } from "./generated/schema";
|
import type { components } from "./generated/schema";
|
||||||
import { apiErrorCode } from "./problem-details";
|
import { apiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
@@ -107,8 +108,14 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
|||||||
}, [client, group, query.error]);
|
}, [client, group, query.error]);
|
||||||
const observe = async (expectedStateVersion?: string) => {
|
const observe = async (expectedStateVersion?: string) => {
|
||||||
const queryFn = guarded;
|
const queryFn = guarded;
|
||||||
if (typeof queryFn !== "function") return;
|
if (
|
||||||
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
!query.isEnabled ||
|
||||||
|
!getAuthenticatedSessionSnapshot() ||
|
||||||
|
typeof queryFn !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// Cancellation is synchronous; start the replacement in the same boundary.
|
||||||
|
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
||||||
return client.fetchQuery({
|
return client.fetchQuery({
|
||||||
...options,
|
...options,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
@@ -144,7 +151,11 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return { ...unwrapResourceResult(query), observe };
|
return {
|
||||||
|
...unwrapResourceResult(query),
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
observe,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
|
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
|
||||||
|
|||||||
@@ -2668,6 +2668,7 @@
|
|||||||
"name": "from",
|
"name": "from",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2677,6 +2678,7 @@
|
|||||||
"name": "to",
|
"name": "to",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2878,6 +2880,7 @@
|
|||||||
"name": "from",
|
"name": "from",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2887,6 +2890,7 @@
|
|||||||
"name": "to",
|
"name": "to",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2984,7 +2988,7 @@
|
|||||||
"name": "from",
|
"name": "from",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
|
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2994,7 +2998,7 @@
|
|||||||
"name": "to",
|
"name": "to",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
|
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -3100,7 +3104,7 @@
|
|||||||
"name": "from",
|
"name": "from",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
|
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -3110,7 +3114,7 @@
|
|||||||
"name": "to",
|
"name": "to",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
|
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "date",
|
"format": "date",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5001,7 +5005,31 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/InvestmentScreenDto"
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"discriminator": {
|
||||||
|
"propertyName": "screen",
|
||||||
|
"mapping": {
|
||||||
|
"income": "#/components/schemas/IncomeInvestmentScreenDto",
|
||||||
|
"events": "#/components/schemas/EventsInvestmentScreenDto",
|
||||||
|
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"account": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6878,6 +6906,129 @@
|
|||||||
"entries"
|
"entries"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IncomeDependencyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dataRevision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"stateVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"unavailableReason": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"$ref": "#/components/schemas/InvestmentCoverageDto"
|
||||||
|
},
|
||||||
|
"resourceKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"missing",
|
||||||
|
"partial",
|
||||||
|
"complete"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"unknown",
|
||||||
|
"fresh",
|
||||||
|
"stale"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lastSuccessfulAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"$ref": "#/components/schemas/InvestmentSyncDto"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"selected",
|
||||||
|
"previous",
|
||||||
|
"year"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"dataRevision",
|
||||||
|
"stateVersion",
|
||||||
|
"resourceKey",
|
||||||
|
"availability",
|
||||||
|
"freshness",
|
||||||
|
"lastSuccessfulAt",
|
||||||
|
"sync",
|
||||||
|
"role"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"IncomeAnalyticsMetaDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dataRevision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"stateVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"unavailableReason": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"$ref": "#/components/schemas/InvestmentCoverageDto"
|
||||||
|
},
|
||||||
|
"resourceKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"missing",
|
||||||
|
"partial",
|
||||||
|
"complete"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"unknown",
|
||||||
|
"fresh",
|
||||||
|
"stale"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lastSuccessfulAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"$ref": "#/components/schemas/InvestmentSyncDto"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"description": "Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IncomeDependencyDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"dataRevision",
|
||||||
|
"stateVersion",
|
||||||
|
"resourceKey",
|
||||||
|
"availability",
|
||||||
|
"freshness",
|
||||||
|
"lastSuccessfulAt",
|
||||||
|
"sync",
|
||||||
|
"dependencies"
|
||||||
|
]
|
||||||
|
},
|
||||||
"IncomeAnalyticsResourceDto": {
|
"IncomeAnalyticsResourceDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6891,7 +7042,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
|
"$ref": "#/components/schemas/IncomeAnalyticsMetaDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -8677,6 +8828,62 @@
|
|||||||
"token"
|
"token"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IncomeInvestmentScreenDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"income"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accountId",
|
||||||
|
"screen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EventsInvestmentScreenDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"events"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accountId",
|
||||||
|
"screen"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SyncCandleRangeDto": {
|
"SyncCandleRangeDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8694,34 +8901,12 @@
|
|||||||
"to"
|
"to"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"InvestmentScreenDto": {
|
"TimestampInvestmentScreenDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"screen": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"accounts",
|
|
||||||
"account",
|
|
||||||
"operations",
|
|
||||||
"income",
|
|
||||||
"events",
|
|
||||||
"stocks",
|
|
||||||
"bonds",
|
|
||||||
"stock",
|
|
||||||
"bond"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accountId": {
|
"accountId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"from": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
"to": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
"instrumentUid": {
|
"instrumentUid": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
@@ -8759,6 +8944,28 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 1000
|
"maximum": 1000
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"accounts",
|
||||||
|
"account",
|
||||||
|
"operations",
|
||||||
|
"stocks",
|
||||||
|
"bonds",
|
||||||
|
"stock",
|
||||||
|
"bond"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Start instant for operations."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "End instant for operations."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -8773,7 +8980,31 @@
|
|||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"$ref": "#/components/schemas/InvestmentScreenDto"
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"discriminator": {
|
||||||
|
"propertyName": "screen",
|
||||||
|
"mapping": {
|
||||||
|
"income": "#/components/schemas/IncomeInvestmentScreenDto",
|
||||||
|
"events": "#/components/schemas/EventsInvestmentScreenDto",
|
||||||
|
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"account": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"now": {
|
"now": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -8813,13 +9044,32 @@
|
|||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "object",
|
|
||||||
"allOf": [
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/InvestmentScreenDto"
|
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"discriminator": {
|
||||||
|
"propertyName": "screen",
|
||||||
|
"mapping": {
|
||||||
|
"income": "#/components/schemas/IncomeInvestmentScreenDto",
|
||||||
|
"events": "#/components/schemas/EventsInvestmentScreenDto",
|
||||||
|
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"account": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
|
||||||
|
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
},
|
},
|
||||||
"now": {
|
"now": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const ru = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
stock: {
|
stock: {
|
||||||
|
currencyUnknown: "валюта неизвестна",
|
||||||
searchTitle: "Акции",
|
searchTitle: "Акции",
|
||||||
searchSubtitle:
|
searchSubtitle:
|
||||||
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
|
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
|
||||||
@@ -636,6 +637,7 @@ const ru = {
|
|||||||
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
||||||
comparisonFailed:
|
comparisonFailed:
|
||||||
"Не удалось загрузить период сравнения. Повторите обновление.",
|
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||||
|
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
|
||||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||||
difference: "Разница",
|
difference: "Разница",
|
||||||
total: "Итого",
|
total: "Итого",
|
||||||
@@ -659,6 +661,10 @@ const ru = {
|
|||||||
},
|
},
|
||||||
settings: { title: "Настройки" },
|
settings: { title: "Настройки" },
|
||||||
tbank: {
|
tbank: {
|
||||||
|
close: "Закрыть",
|
||||||
|
refreshError:
|
||||||
|
"Не удалось обновить статус подключения. Показаны последние полученные данные.",
|
||||||
|
refreshing: "Обновляем статус подключения…",
|
||||||
title: "T-Bank Invest",
|
title: "T-Bank Invest",
|
||||||
disconnected: "Не подключено",
|
disconnected: "Не подключено",
|
||||||
connected: "Подключено с {{date}}",
|
connected: "Подключено с {{date}}",
|
||||||
@@ -683,6 +689,7 @@ const ru = {
|
|||||||
title: "Не удалось открыть страницу",
|
title: "Не удалось открыть страницу",
|
||||||
text: "Перейдите в безопасный раздел и попробуйте снова.",
|
text: "Перейдите в безопасный раздел и попробуйте снова.",
|
||||||
action: "Продолжить",
|
action: "Продолжить",
|
||||||
|
reload: "Перезагрузить страницу",
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
loadingTitle: "Загрузка",
|
loadingTitle: "Загрузка",
|
||||||
|
|||||||
@@ -177,6 +177,33 @@ const meta = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
|
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
|
||||||
|
export const SecondaryText: Story = {
|
||||||
|
name: "Вторичный текст",
|
||||||
|
render: () => (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||||
|
{[
|
||||||
|
"var(--wl-color-surface)",
|
||||||
|
"var(--wl-color-canvas)",
|
||||||
|
"var(--wl-color-subtle)",
|
||||||
|
...Object.keys(designTokens.color.palettes)
|
||||||
|
.filter((palette) => palette !== "dark")
|
||||||
|
.flatMap((palette) => [
|
||||||
|
`var(--mantine-color-${palette}-0)`,
|
||||||
|
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
|
||||||
|
]),
|
||||||
|
].map((background) => (
|
||||||
|
<Paper key={background} p="md" withBorder style={{ background }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
22.09.2026 · Синтетические данные
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Не удалось обновить данные. Сохранён предыдущий результат.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
),
|
||||||
|
};
|
||||||
export const Typography: Story = {
|
export const Typography: Story = {
|
||||||
name: "Типографика",
|
name: "Типографика",
|
||||||
render: () => <TypographyView />,
|
render: () => <TypographyView />,
|
||||||
|
|||||||
@@ -9,16 +9,21 @@ function luminance(color: string) {
|
|||||||
);
|
);
|
||||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||||
}
|
}
|
||||||
describe("financial text contrast", () => {
|
describe("semantic text contrast", () => {
|
||||||
it.each(["positive", "negative"] as const)(
|
it.each(["positive", "negative", "muted"] as const)(
|
||||||
"keeps %s readable on journal, position and tinted card surfaces",
|
"keeps %s readable on canvas, white, subtle and tinted surfaces",
|
||||||
(tone) => {
|
(tone) => {
|
||||||
const role = tokens.color.roles[tone];
|
const role = tokens.color.roles[tone];
|
||||||
const palettes = tokens.color.palettes;
|
const palettes = tokens.color.palettes;
|
||||||
const foreground =
|
const foreground =
|
||||||
palettes[role.color as keyof typeof palettes][role.shade];
|
palettes[role.color as keyof typeof palettes][role.shade];
|
||||||
const surfaces = [
|
const surfaces = [
|
||||||
|
"#ffffff",
|
||||||
palettes.gray[0],
|
palettes.gray[0],
|
||||||
|
palettes.gray[2],
|
||||||
|
...Object.entries(palettes)
|
||||||
|
.filter(([name]) => name !== "dark")
|
||||||
|
.map(([, palette]) => palette[0]),
|
||||||
...Object.entries(palettes)
|
...Object.entries(palettes)
|
||||||
.filter(([name]) => name !== "dark")
|
.filter(([name]) => name !== "dark")
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@wealthledger/ui": ["packages/ui/src/index.ts"],
|
||||||
|
"@wealthledger/ui/theme": ["packages/ui/src/theme.ts"],
|
||||||
|
"@wealthledger/ui/styles.css": ["packages/ui/src/styles.css"],
|
||||||
|
"@wealthledger/icons": ["packages/icons/src/index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
".storybook/main.ts",
|
".storybook/main.ts",
|
||||||
"packages/*/vite.config.ts"
|
"packages/*/vite.config.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export default defineConfig({
|
|||||||
exclude: configDefaults.exclude,
|
exclude: configDefaults.exclude,
|
||||||
setupFiles: "./src/test/setup.ts",
|
setupFiles: "./src/test/setup.ts",
|
||||||
coverage: {
|
coverage: {
|
||||||
all: true,
|
|
||||||
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
|
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
|
||||||
reporter: ["text", "html", "lcov"],
|
reporter: ["text", "html", "lcov"],
|
||||||
|
|||||||
Reference in New Issue
Block a user