fix: resolve frontend audit findings #39
+10
-7
@@ -22,13 +22,7 @@
|
||||
!packages/ui/vite.config.ts
|
||||
!packages/ui/tsconfig.json
|
||||
!packages/ui/src/
|
||||
!packages/ui/src/theme.ts
|
||||
!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/ui/src/**
|
||||
!packages/icons/
|
||||
!packages/icons/package.json
|
||||
!packages/icons/tsconfig.json
|
||||
@@ -40,3 +34,12 @@
|
||||
!packages/icons/src/svg/AddIcon.tsx
|
||||
!packages/icons/src/svg/EditIcon.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 = {
|
||||
forbidden: [
|
||||
{
|
||||
name: "no-unresolved",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { couldNotResolve: true },
|
||||
},
|
||||
{
|
||||
name: "packages-must-not-depend-on-app",
|
||||
severity: "error",
|
||||
@@ -57,7 +63,11 @@ module.exports = {
|
||||
options: {
|
||||
exclude: "^packages/[^/]+/dist/",
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"openapi:check": "node scripts/check-openapi.mjs",
|
||||
"knip": "npm run build:packages && knip",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"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": {
|
||||
"color": "gray",
|
||||
"shade": 6,
|
||||
"shade": 7,
|
||||
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
||||
},
|
||||
"border": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest";
|
||||
import { EntryCard } from "./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 = {
|
||||
title: "Пример",
|
||||
metadata: "Описание",
|
||||
@@ -31,5 +31,15 @@ describe("EntryCard", () => {
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
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 { type FC, type ReactNode, useState } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import classes from "./EntryCard.module.css";
|
||||
|
||||
type EntryCardProps = {
|
||||
@@ -17,7 +17,6 @@ type EntryCardProps = {
|
||||
};
|
||||
|
||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
return (
|
||||
<Paper
|
||||
className={classes.row}
|
||||
@@ -35,8 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
aria-hidden="true"
|
||||
radius="md"
|
||||
size={40}
|
||||
src={imageFailed ? null : props.imageSrc}
|
||||
onError={() => setImageFailed(true)}
|
||||
src={props.imageSrc}
|
||||
>
|
||||
{props.icon ?? props.initial}
|
||||
</Avatar>
|
||||
|
||||
@@ -25,5 +25,8 @@ describe("design theme adapter", () => {
|
||||
expect(Object.keys(variables)).toHaveLength(
|
||||
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),
|
||||
]),
|
||||
),
|
||||
light: {},
|
||||
light: {
|
||||
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
|
||||
},
|
||||
dark: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { FC } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type FC, lazy, Suspense } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
authenticated = false;
|
||||
renderBoundary("/login");
|
||||
|
||||
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||
<Button component={Link} to={to}>
|
||||
{t("routeError.action")}
|
||||
</Button>
|
||||
<Button component="a" href={to} variant="default">
|
||||
{t("routeError.reload")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { pathname, search } = useLocation();
|
||||
const { pathname, search, key } = useLocation();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
|
||||
<Boundary
|
||||
authenticated={Boolean(user)}
|
||||
routeKey={`${pathname}${search}:${key}`}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
|
||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||
}
|
||||
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
|
||||
: 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 { bankMonth } from "./form-validation";
|
||||
|
||||
const calendarDate = z.string().date();
|
||||
const ISO_DATE_LENGTH = 10;
|
||||
function bankPeriod(params: URLSearchParams) {
|
||||
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 to = params.get("to");
|
||||
return from &&
|
||||
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
|
||||
? { from, to }
|
||||
: {
|
||||
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 { 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 { describe, expect, it } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
|
||||
import { CategoryManager } from "./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 () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const editSession = useRef(0);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||
const methods = useForm<CategoryValues>({
|
||||
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
editSession.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
editSession.current++;
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
const session = editSession.current;
|
||||
const response = await categories.refetch();
|
||||
if (session !== editSession.current) return;
|
||||
if (response.error) setError(response.error);
|
||||
else
|
||||
setLatest(
|
||||
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{latest && (
|
||||
{latest && latest.id === editing?.id && (
|
||||
<>
|
||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||
<Button
|
||||
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
editSession.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
}}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { useScreenSync } from "../index";
|
||||
import { commitTbankConnectionBoundary } from "./connection-boundary";
|
||||
|
||||
function auth() {
|
||||
configureAuthClient({
|
||||
@@ -16,6 +18,207 @@ function auth() {
|
||||
});
|
||||
}
|
||||
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 () => {
|
||||
auth();
|
||||
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 {
|
||||
apiErrorCode,
|
||||
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||
type ResourceMeta =
|
||||
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
type ScreenResource = {
|
||||
queryKey: QueryKey;
|
||||
meta?: ResourceMeta;
|
||||
transport: {
|
||||
status: "pending" | "success" | "error";
|
||||
fetchStatus: "idle" | "fetching" | "paused";
|
||||
};
|
||||
observe: (version?: string) => Promise<unknown>;
|
||||
};
|
||||
const STATUS_INTERVAL_MS = 1000;
|
||||
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
latest.set(dependency.resourceKey, dependency);
|
||||
}
|
||||
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 = [
|
||||
resource.meta,
|
||||
...(resource.meta.dependencies ?? []),
|
||||
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
);
|
||||
void resource.observe(own?.stateVersion).catch(() => undefined);
|
||||
}
|
||||
}, [client, statuses, resources]);
|
||||
}, [client, statuses, resources, requested, scopeKey]);
|
||||
const refreshing =
|
||||
(currentMutation && mutation.isPending) ||
|
||||
statuses.some(
|
||||
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
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 {
|
||||
refresh,
|
||||
retry,
|
||||
refreshing,
|
||||
transportError:
|
||||
(currentMutation && mutation.isError) ||
|
||||
|
||||
+119
-7
@@ -1,11 +1,18 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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 { describe, expect, it, vi } from "vitest";
|
||||
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
||||
import { TbankConnectionSection } from "./TbankConnectionSection";
|
||||
|
||||
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
||||
@@ -78,6 +85,74 @@ function seededClient() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const client = seededClient();
|
||||
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(
|
||||
http.get("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({
|
||||
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
|
||||
connectedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
),
|
||||
http.delete("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
http.delete("*/api/v1/tbank-connection", async () => {
|
||||
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();
|
||||
renderSection(client);
|
||||
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
|
||||
fireEvent.click(
|
||||
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(
|
||||
screen.getByRole("button", { name: "Заменить token" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
|
||||
expect(client.getQueryData(portfolioQueryKey)).toEqual({
|
||||
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 === "error")
|
||||
return <TbankConnectionError retry={viewModel.retry} />;
|
||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
||||
return <TbankConnectionSectionLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
export { TbankConnectionSection };
|
||||
|
||||
+10
-3
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
|
||||
const status = useAuthenticatedQuery({
|
||||
...tbankConnectionQueryOptions(),
|
||||
});
|
||||
if (status.isPending) return { state: "loading" as const };
|
||||
if (status.isError)
|
||||
if (!status.data) {
|
||||
if (status.isPending) return { state: "loading" as const };
|
||||
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 };
|
||||
|
||||
+36
-3
@@ -8,7 +8,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
|
||||
type TbankConnectionSectionLoadedProps = {
|
||||
status: TbankConnectionStatus;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const deleteButton = useRef<HTMLButtonElement>(null);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
});
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
},
|
||||
);
|
||||
const pending = update.isPending || remove.isPending;
|
||||
const connected = status.connected;
|
||||
useEffect(() => {
|
||||
if (deleteOpened && remove.isError) deleteButton.current?.focus();
|
||||
}, [deleteOpened, remove.isError]);
|
||||
|
||||
return (
|
||||
<Stack mt="xl">
|
||||
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("tbank.hint")}
|
||||
</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
|
||||
onSubmit={handleSubmit(({ token }) => {
|
||||
setRequestError(null);
|
||||
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteOpened(true)}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setDeleteOpened(true);
|
||||
}}
|
||||
>
|
||||
{t("tbank.delete")}
|
||||
</Button>
|
||||
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
title={t("tbank.deleteTitle")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
|
||||
>
|
||||
<Stack>
|
||||
<Text>{t("tbank.deleteText")}</Text>
|
||||
{remove.isError && (
|
||||
<Alert color="red">{t("tbank.requestError")}</Alert>
|
||||
)}
|
||||
<Button
|
||||
ref={deleteButton}
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
disabled={pending}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
History back
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate(1)}>
|
||||
History forward
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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"])(
|
||||
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
|
||||
async (accountId) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
|
||||
vi.mock("@/features/tbank-connection", () => ({
|
||||
useScreenSync: () => ({
|
||||
refresh,
|
||||
retry: refresh,
|
||||
refreshing: false,
|
||||
sourceError: false,
|
||||
transportError: false,
|
||||
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
renderPage();
|
||||
expect(query).toHaveBeenCalledWith(undefined, {});
|
||||
expect(query).toHaveBeenCalledWith("", {});
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
|
||||
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
||||
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||
if (viewModel.state === "error")
|
||||
return <AccountEventsPageError retry={viewModel.retry} />;
|
||||
return <AccountEventsPageError {...viewModel} />;
|
||||
return <AccountEventsPageLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
|
||||
|
||||
type AccountEventsPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void }
|
||||
| { state: "error"; retry: () => void; onReset: () => void }
|
||||
| {
|
||||
state: "loaded";
|
||||
events: BrokerageEvent[];
|
||||
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
|
||||
scopeId?: string,
|
||||
): AccountEventsPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const appliedFrom = searchParams.get("from");
|
||||
const appliedTo = searchParams.get("to");
|
||||
const urlFrom = searchParams.get("from");
|
||||
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 [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(() => {
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
|
||||
},
|
||||
[events],
|
||||
);
|
||||
const onReset = () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setSearchParams({});
|
||||
};
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
return {
|
||||
state: "error",
|
||||
onReset,
|
||||
retry: () => {
|
||||
void sync.refresh();
|
||||
void sync.retry();
|
||||
},
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
|
||||
refreshing: events.isFetching || sync.refreshing,
|
||||
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||
retry: () => {
|
||||
void sync.refresh();
|
||||
void sync.retry();
|
||||
},
|
||||
draftFrom,
|
||||
draftTo,
|
||||
canApply:
|
||||
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
|
||||
(!draftFrom || !draftTo || draftFrom <= draftTo),
|
||||
isValidPeriod(draftFrom, draftTo),
|
||||
onDraftFromChange: setDraftFrom,
|
||||
onDraftToChange: setDraftTo,
|
||||
onApply: () =>
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParams({
|
||||
...(draftFrom ? { from: draftFrom } : {}),
|
||||
...(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 };
|
||||
|
||||
+10
-3
@@ -1,9 +1,9 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AccountEventsPageErrorProps = { retry: () => void };
|
||||
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
|
||||
|
||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
error
|
||||
title={t("events.errorTitle")}
|
||||
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,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { isDate } from "../../model/period";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||
import {
|
||||
toIncomeDisplayModel,
|
||||
useAccountIncomePageViewModel,
|
||||
} from "./AccountIncomePage.vm";
|
||||
import { IncomePayments } from "./components/IncomePayments.view";
|
||||
|
||||
const analytics = {
|
||||
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const requested: string[] = [];
|
||||
server.use(
|
||||
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
|
||||
});
|
||||
|
||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||
const dependencies = [
|
||||
{ ...resource(analytics).meta, role: "selected" as const },
|
||||
{ ...resource(analytics).meta, role: "previous" as const },
|
||||
{
|
||||
...resource(null).meta,
|
||||
role: "year" as const,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
},
|
||||
];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(analytics, {
|
||||
dependencies: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
HttpResponse.json(resource(analytics, { dependencies })),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
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 }),
|
||||
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 () => {
|
||||
const dependencies = [
|
||||
{ ...resource(analytics).meta, role: "previous" as const },
|
||||
{
|
||||
...resource(null).meta,
|
||||
role: "year" as const,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed" as const,
|
||||
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: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
HttpResponse.json(resource(analytics, { dependencies })),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
type ComparisonNotice =
|
||||
| "comparisonPending"
|
||||
| "comparisonFailed"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
|
||||
scopeId?: string,
|
||||
): AccountIncomePageViewModel => {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = useMemo(
|
||||
() => ({
|
||||
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
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" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
refreshing: analytics.isFetching || sync.refreshing,
|
||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
form,
|
||||
onSubmit,
|
||||
onReset,
|
||||
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
|
||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||
}
|
||||
|
||||
// The income endpoint returns selected, previous and year dependencies in order.
|
||||
const [, previous, year] = meta?.dependencies ?? [];
|
||||
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
|
||||
const year = meta?.dependencies?.find(({ role }) => role === "year");
|
||||
return {
|
||||
entries,
|
||||
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||
previousNotice: comparisonNotice(previous),
|
||||
yearNotice: comparisonNotice(year),
|
||||
previousNotice: comparisonNotice(previous, "previousUnavailable"),
|
||||
yearNotice: comparisonNotice(year, "yearUnavailable"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,9 +252,10 @@ function comparisonNotice(
|
||||
dependency:
|
||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||
| undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (dependency?.unavailableReason) return "yearUnavailable";
|
||||
return dependency?.sync.state === "failed"
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
return dependency.sync.state === "failed"
|
||||
? "comparisonFailed"
|
||||
: "comparisonPending";
|
||||
}
|
||||
|
||||
+198
-1
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
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 {
|
||||
type BrokerageOperation,
|
||||
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
|
||||
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() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
configureTestAuth();
|
||||
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 () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
type AccountOperationsPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void }
|
||||
| { state: "error"; retry: () => void; onReset: () => void }
|
||||
| {
|
||||
state: "loaded";
|
||||
sourceError?: boolean;
|
||||
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const appliedFrom = searchParams.get("from");
|
||||
const appliedTo = searchParams.get("to");
|
||||
const urlFrom = searchParams.get("from");
|
||||
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) =>
|
||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||
);
|
||||
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
|
||||
useState<OperationCategory[]>(appliedCategories);
|
||||
const scopeKey = JSON.stringify([
|
||||
accountId,
|
||||
appliedFrom,
|
||||
appliedTo,
|
||||
urlFrom,
|
||||
urlTo,
|
||||
appliedCategories,
|
||||
]);
|
||||
const [pagination, setPagination] = useState<{
|
||||
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
|
||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||
const cursor = cursors.at(-1);
|
||||
useEffect(() => {
|
||||
setDraftFrom(searchParams.get("from"));
|
||||
setDraftTo(searchParams.get("to"));
|
||||
setPagination((current) =>
|
||||
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(
|
||||
operationCategories.filter((category) =>
|
||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||
),
|
||||
);
|
||||
}, [searchParams]);
|
||||
}, [appliedFrom, appliedTo, searchParams]);
|
||||
const firstPageQuery = {
|
||||
from: dateStart(appliedFrom),
|
||||
to: dateEnd(appliedTo),
|
||||
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
|
||||
exact: true,
|
||||
});
|
||||
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" };
|
||||
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
|
||||
(!operations.data ||
|
||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||
)
|
||||
return { state: "error", retry };
|
||||
return { state: "error", retry, onReset };
|
||||
if (!operations.data) return { state: "loading" };
|
||||
const nextCursor = operations.data.nextCursor;
|
||||
|
||||
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
|
||||
items: operations.data.items,
|
||||
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
||||
hasPrevious: Boolean(cursors.length),
|
||||
canApply: isDirty,
|
||||
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
|
||||
onDraftFromChange: setDraftFrom,
|
||||
onDraftToChange: setDraftTo,
|
||||
onDraftCategoriesChange: setDraftCategories,
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParams({
|
||||
...(draftFrom ? { from: draftFrom } : {}),
|
||||
...(draftTo ? { to: draftTo } : {}),
|
||||
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
|
||||
});
|
||||
resetCursors();
|
||||
},
|
||||
onReset: () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setDraftCategories([]);
|
||||
setSearchParams({});
|
||||
resetCursors();
|
||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||
},
|
||||
onReset,
|
||||
onNext: () => {
|
||||
if (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) {
|
||||
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 type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
||||
error
|
||||
title={t("ledger.errorTitle")}
|
||||
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 {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
}
|
||||
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
refreshing: portfolio.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
};
|
||||
if (
|
||||
portfolio.meta?.availability === "complete" &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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 { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
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([
|
||||
["loading", AccountsPageLoading, "status"],
|
||||
["empty", AccountsPageEmpty, undefined],
|
||||
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
|
||||
expect(screen.getByText(account.name)).toBeInTheDocument();
|
||||
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
|
||||
expect(requestCount).toBe(2);
|
||||
await waitFor(() => expect(requestCount).toBe(3));
|
||||
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 () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(accounts.error),
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
};
|
||||
}
|
||||
if (!accounts.data?.length)
|
||||
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
};
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
sourceError: 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,
|
||||
exact: true,
|
||||
});
|
||||
} else void sync.refresh();
|
||||
} else void sync.retry();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
@@ -99,26 +99,26 @@ function useBondPageVm() {
|
||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
bond: bond.data,
|
||||
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
|
||||
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
|
||||
code: apiErrorCode(bond.error),
|
||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||
metrics: metrics.data,
|
||||
metricsState: section(metrics, sync.refresh),
|
||||
metricsState: section(metrics, sync.retry),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote, sync.refresh),
|
||||
quoteState: section(quote, sync.retry),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles, sync.refresh),
|
||||
candlesState: section(candles, sync.retry),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine: null,
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position, sync.refresh),
|
||||
positionState: section(position, sync.retry),
|
||||
coupons: coupons.data,
|
||||
couponsState: section(coupons, sync.refresh),
|
||||
couponsState: section(coupons, sync.retry),
|
||||
upcoming:
|
||||
coupons.data?.coupons
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
@@ -137,7 +137,7 @@ function useBondPageVm() {
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: {
|
||||
...section(operations, sync.refresh),
|
||||
...section(operations, sync.retry),
|
||||
retry: retryOperations,
|
||||
},
|
||||
eventRows:
|
||||
|
||||
@@ -28,7 +28,7 @@ function useBondsPageVm() {
|
||||
partial: result.meta?.availability === "partial",
|
||||
code: apiErrorCode(result.error),
|
||||
retry: () => {
|
||||
void sync.refresh();
|
||||
void sync.retry();
|
||||
},
|
||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||
items:
|
||||
|
||||
@@ -46,6 +46,7 @@ const operation = {
|
||||
};
|
||||
function setup(
|
||||
options: {
|
||||
month?: string;
|
||||
missing?: boolean;
|
||||
summaryError?: boolean;
|
||||
cardsError?: boolean;
|
||||
@@ -101,7 +102,9 @@ function setup(
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MantineProvider env="test">
|
||||
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/cards/:cardId" element={<CardPage />} />
|
||||
</Routes>
|
||||
@@ -112,6 +115,26 @@ function setup(
|
||||
return { requests, summaryRequests, client };
|
||||
}
|
||||
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 () => {
|
||||
const { requests, summaryRequests } = setup();
|
||||
await screen.findByRole("heading", { name: "Повседневная" });
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { StockPage } from "./StockPage";
|
||||
import type { StockPageVm } from "./StockPage.vm";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
@@ -72,6 +73,7 @@ function setup(
|
||||
path?: string;
|
||||
slug?: string;
|
||||
ambiguousTicker?: boolean;
|
||||
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
|
||||
} = {},
|
||||
) {
|
||||
const requests: URL[] = [];
|
||||
@@ -118,7 +120,7 @@ function setup(
|
||||
: HttpResponse.json(
|
||||
resource({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
metrics: options.metrics ?? [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
@@ -215,6 +217,63 @@ function setup(
|
||||
};
|
||||
}
|
||||
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 () => {
|
||||
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
@@ -290,6 +349,31 @@ describe("stock page", () => {
|
||||
),
|
||||
).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 () => {
|
||||
const { requests } = setup();
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
|
||||
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
metric.unit === "money" ? "compact" : "standard",
|
||||
}).format(metric.value)}{" "}
|
||||
{metric.unit === "money"
|
||||
? new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: metric.currency ?? "RUB",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
})
|
||||
.formatToParts(0)
|
||||
.find((part) => part.type === "currency")?.value
|
||||
? metric.currency === "RUB"
|
||||
? "₽"
|
||||
: (metric.currency ?? t("stock.currencyUnknown"))
|
||||
: metric.unit === "percent"
|
||||
? "%"
|
||||
: "×"}
|
||||
|
||||
@@ -82,7 +82,7 @@ function useStockPageVm() {
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void sync.refresh();
|
||||
} else void sync.retry();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
@@ -105,15 +105,15 @@ function useStockPageVm() {
|
||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
stock: stock.data,
|
||||
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
|
||||
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
|
||||
code: apiErrorCode(stock.error),
|
||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote, sync.refresh),
|
||||
quoteState: section(quote, sync.retry),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles, sync.refresh),
|
||||
candlesState: section(candles, sync.retry),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine:
|
||||
@@ -121,11 +121,11 @@ function useStockPageVm() {
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position, sync.refresh),
|
||||
positionState: section(position, sync.retry),
|
||||
company: companyData,
|
||||
companyState: section(company, sync.refresh),
|
||||
companyState: section(company, sync.retry),
|
||||
dividends: dividends.data,
|
||||
dividendsState: section(dividends, sync.refresh),
|
||||
dividendsState: section(dividends, sync.retry),
|
||||
upcoming:
|
||||
dividends.data?.dividends
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
@@ -144,7 +144,7 @@ function useStockPageVm() {
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: {
|
||||
...section(operations, sync.refresh),
|
||||
...section(operations, sync.retry),
|
||||
retry: retryOperations,
|
||||
},
|
||||
eventRows:
|
||||
|
||||
@@ -28,7 +28,7 @@ function useStocksPageVm() {
|
||||
partial: result.meta?.availability === "partial",
|
||||
code: apiErrorCode(result.error),
|
||||
retry: () => {
|
||||
void sync.refresh();
|
||||
void sync.retry();
|
||||
},
|
||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||
items:
|
||||
|
||||
Vendored
+96
-15
@@ -1124,9 +1124,41 @@ export interface components {
|
||||
instruments: components["schemas"]["IncomeInstrumentDto"][];
|
||||
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: {
|
||||
data: components["schemas"]["IncomeAnalyticsDto"] | null;
|
||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
|
||||
};
|
||||
UpcomingEventDto: {
|
||||
account?: components["schemas"]["AccountReferenceDto"];
|
||||
@@ -1515,20 +1547,50 @@ export interface components {
|
||||
PutTbankConnectionDto: {
|
||||
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: {
|
||||
/** Format: date-time */
|
||||
from: string;
|
||||
/** Format: date-time */
|
||||
to: string;
|
||||
};
|
||||
InvestmentScreenDto: {
|
||||
/** @enum {string} */
|
||||
screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond";
|
||||
TimestampInvestmentScreenDto: {
|
||||
accountId?: string;
|
||||
/** Format: date-time */
|
||||
from?: string;
|
||||
/** Format: date-time */
|
||||
to?: string;
|
||||
/** Format: uuid */
|
||||
instrumentUid?: string;
|
||||
query?: string;
|
||||
@@ -1537,11 +1599,26 @@ export interface components {
|
||||
candleRange?: components["schemas"]["SyncCandleRangeDto"];
|
||||
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
|
||||
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: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
scope: components["schemas"]["InvestmentScreenDto"];
|
||||
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
|
||||
/** Format: date-time */
|
||||
now: string;
|
||||
};
|
||||
@@ -1553,7 +1630,7 @@ export interface components {
|
||||
TbankSyncStatusDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
scope: components["schemas"]["InvestmentScreenDto"] | null;
|
||||
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
|
||||
/** Format: date-time */
|
||||
now: string;
|
||||
/** @enum {string} */
|
||||
@@ -3525,7 +3602,9 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
sync?: "observe";
|
||||
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
|
||||
from?: string;
|
||||
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
@@ -3661,7 +3740,9 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
sync?: "observe";
|
||||
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
|
||||
from?: string;
|
||||
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
@@ -3731,9 +3812,9 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
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;
|
||||
/** @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;
|
||||
};
|
||||
header?: never;
|
||||
@@ -3801,9 +3882,9 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
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;
|
||||
/** @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;
|
||||
};
|
||||
header?: never;
|
||||
@@ -5160,7 +5241,7 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["InvestmentScreenDto"];
|
||||
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||
import type { components } from "./generated/schema";
|
||||
import { apiErrorCode } from "./problem-details";
|
||||
|
||||
@@ -107,8 +108,14 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
}, [client, group, query.error]);
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFn = guarded;
|
||||
if (typeof queryFn !== "function") return;
|
||||
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
||||
if (
|
||||
!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({
|
||||
...options,
|
||||
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 };
|
||||
|
||||
@@ -2668,6 +2668,7 @@
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -2677,6 +2678,7 @@
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -2878,6 +2880,7 @@
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -2887,6 +2890,7 @@
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -2984,7 +2988,7 @@
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"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": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -2994,7 +2998,7 @@
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"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": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -3100,7 +3104,7 @@
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"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": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -3110,7 +3114,7 @@
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"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": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
@@ -5001,7 +5005,31 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6891,7 +7042,7 @@
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
|
||||
"$ref": "#/components/schemas/IncomeAnalyticsMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -8677,6 +8828,62 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8694,34 +8901,12 @@
|
||||
"to"
|
||||
]
|
||||
},
|
||||
"InvestmentScreenDto": {
|
||||
"TimestampInvestmentScreenDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"screen": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accounts",
|
||||
"account",
|
||||
"operations",
|
||||
"income",
|
||||
"events",
|
||||
"stocks",
|
||||
"bonds",
|
||||
"stock",
|
||||
"bond"
|
||||
]
|
||||
},
|
||||
"accountId": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"instrumentUid": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -8759,6 +8944,28 @@
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"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": [
|
||||
@@ -8773,7 +8980,31 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -8813,13 +9044,32 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"scope": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
"oneOf": [
|
||||
{
|
||||
"$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": {
|
||||
"type": "string",
|
||||
|
||||
@@ -269,6 +269,7 @@ const ru = {
|
||||
},
|
||||
},
|
||||
stock: {
|
||||
currencyUnknown: "валюта неизвестна",
|
||||
searchTitle: "Акции",
|
||||
searchSubtitle:
|
||||
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
|
||||
@@ -636,6 +637,7 @@ const ru = {
|
||||
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
||||
comparisonFailed:
|
||||
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
|
||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||
difference: "Разница",
|
||||
total: "Итого",
|
||||
@@ -659,6 +661,10 @@ const ru = {
|
||||
},
|
||||
settings: { title: "Настройки" },
|
||||
tbank: {
|
||||
close: "Закрыть",
|
||||
refreshError:
|
||||
"Не удалось обновить статус подключения. Показаны последние полученные данные.",
|
||||
refreshing: "Обновляем статус подключения…",
|
||||
title: "T-Bank Invest",
|
||||
disconnected: "Не подключено",
|
||||
connected: "Подключено с {{date}}",
|
||||
@@ -683,6 +689,7 @@ const ru = {
|
||||
title: "Не удалось открыть страницу",
|
||||
text: "Перейдите в безопасный раздел и попробуйте снова.",
|
||||
action: "Продолжить",
|
||||
reload: "Перезагрузить страницу",
|
||||
},
|
||||
states: {
|
||||
loadingTitle: "Загрузка",
|
||||
|
||||
@@ -177,6 +177,33 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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 = {
|
||||
name: "Типографика",
|
||||
render: () => <TypographyView />,
|
||||
|
||||
@@ -9,16 +9,21 @@ function luminance(color: string) {
|
||||
);
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
}
|
||||
describe("financial text contrast", () => {
|
||||
it.each(["positive", "negative"] as const)(
|
||||
"keeps %s readable on journal, position and tinted card surfaces",
|
||||
describe("semantic text contrast", () => {
|
||||
it.each(["positive", "negative", "muted"] as const)(
|
||||
"keeps %s readable on canvas, white, subtle and tinted surfaces",
|
||||
(tone) => {
|
||||
const role = tokens.color.roles[tone];
|
||||
const palettes = tokens.color.palettes;
|
||||
const foreground =
|
||||
palettes[role.color as keyof typeof palettes][role.shade];
|
||||
const surfaces = [
|
||||
"#ffffff",
|
||||
palettes.gray[0],
|
||||
palettes.gray[2],
|
||||
...Object.entries(palettes)
|
||||
.filter(([name]) => name !== "dark")
|
||||
.map(([, palette]) => palette[0]),
|
||||
...Object.entries(palettes)
|
||||
.filter(([name]) => name !== "dark")
|
||||
.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": [
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
".storybook/main.ts",
|
||||
"packages/*/vite.config.ts"
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ export default defineConfig({
|
||||
exclude: configDefaults.exclude,
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
coverage: {
|
||||
all: true,
|
||||
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
|
||||
reporter: ["text", "html", "lcov"],
|
||||
|
||||
Reference in New Issue
Block a user