diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx
index c416b5b..c6fa121 100644
--- a/packages/icons/src/Icons.test.tsx
+++ b/packages/icons/src/Icons.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { AddIcon, DeleteIcon, EditIcon } from "./index";
+import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])(
@@ -34,4 +34,16 @@ describe("icon accessibility", () => {
expect(icon).toHaveAttribute("stroke", "currentColor");
},
);
+ it("keeps operation icons decorative", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ // eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
+ expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
+ "aria-hidden", "true",
+ );
+ });
});
diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx
index 33e87a6..4829b3a 100644
--- a/src/entities/bank-card/api/banking.test.tsx
+++ b/src/entities/bank-card/api/banking.test.tsx
@@ -6,7 +6,13 @@ import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
-import { useBankMutations } from "./mutations";
+import {
+ useCreateBankOperation,
+ useDeleteBankOperation,
+ useUpdateBankCard,
+ useUpdateBankOperation,
+ useUpdateExpenseCategory,
+} from "./mutations";
import {
type BankCard,
type CreateBankOperation,
@@ -111,7 +117,7 @@ function useReadModels() {
const original = useBankOperation("original");
return {
- mutations: useBankMutations(),
+ createOperation: useCreateBankOperation(),
reads: [
cards,
a,
@@ -217,11 +223,11 @@ describe("banking API", () => {
}),
);
- const { result } = renderHook(useBankMutations, {
+ const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper,
});
- await act(() => result.current.createOperation.mutateAsync(transfer));
+ await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
expect(refresh).toHaveBeenCalledTimes(1);
@@ -239,17 +245,17 @@ describe("banking API", () => {
}),
);
- const { result } = renderHook(useBankMutations, {
+ const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper,
});
await act(async () => {
await expect(
- result.current.createOperation.mutateAsync(transfer),
+ result.current.mutateAsync(transfer),
).rejects.toThrow();
});
expect(bodies).toEqual([transfer]);
- await act(() => result.current.createOperation.mutateAsync(transfer));
+ await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
});
@@ -266,20 +272,20 @@ describe("banking API", () => {
);
const { result } = renderHook(
- () => ({ mutations: useBankMutations(), query: useBankCard("a") }),
+ () => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise;
act(() => {
- saving = result.current.mutations.createOperation
+ saving = result.current.createOperation
.mutateAsync(transfer)
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
});
await waitFor(() =>
- expect(result.current.mutations.createOperation.isPending).toBe(true),
+ expect(result.current.createOperation.isPending).toBe(true),
);
expect(result.current.query.data?.balance.value).toBe("100");
await act(async () => {
@@ -313,7 +319,7 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
await act(() =>
- result.current.mutations.createOperation.mutateAsync(transfer),
+ result.current.createOperation.mutateAsync(transfer),
);
expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2);
@@ -336,14 +342,14 @@ describe("banking API", () => {
);
const { result } = renderHook(
- () => ({ card: useBankCard("a"), mutations: useBankMutations() }),
+ () => ({ card: useBankCard("a"), updateCard: useUpdateBankCard() }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => {
await expect(
- result.current.mutations.updateCard.mutateAsync({
+ result.current.updateCard.mutateAsync({
body: { name: "changed", version: 1 },
id: "a",
}),
@@ -389,7 +395,9 @@ describe("banking API", () => {
const { result } = renderHook(
() => ({
categories: useBankCategories(),
- mutations: useBankMutations(),
+ deleteOperation: useDeleteBankOperation(),
+ updateCategory: useUpdateExpenseCategory(),
+ updateOperation: useUpdateBankOperation(),
}),
{ wrapper: createQueryWrapper().wrapper },
);
@@ -398,7 +406,7 @@ describe("banking API", () => {
expect(result.current.categories.data?.[0].name).toBe("Food"),
);
await act(() =>
- result.current.mutations.updateCategory.mutateAsync({
+ result.current.updateCategory.mutateAsync({
body: { name: "Groceries", version: 1 },
id: "food",
}),
@@ -407,13 +415,13 @@ describe("banking API", () => {
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
);
await act(() =>
- result.current.mutations.updateOperation.mutateAsync({
+ result.current.updateOperation.mutateAsync({
body: { description: "corrected", version: 1 },
id: "original",
}),
);
await act(() =>
- result.current.mutations.deleteOperation.mutateAsync({
+ result.current.deleteOperation.mutateAsync({
id: "original",
version: 2,
}),
diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts
index ea486b1..5efa70e 100644
--- a/src/entities/bank-card/api/mutations.ts
+++ b/src/entities/bank-card/api/mutations.ts
@@ -26,7 +26,7 @@ async function mutationData(
return data;
}
-function useBankMutations() {
+function useBankMutationOptions() {
const queryClient = useQueryClient();
const invalidate = () =>
@@ -43,7 +43,7 @@ function useBankMutations() {
},
});
- const options = {
+ return {
onError: (error: unknown) => {
const errorCode = apiErrorCode(error);
if (
@@ -60,14 +60,56 @@ function useBankMutations() {
onSuccess: invalidate,
retry: false,
} as const;
+}
- const cardCreation = useMutation({
+function useCreateBankCard() {
+ const options = useBankMutationOptions();
+
+ return useMutation({
...options,
mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
});
+}
- const updateCard = useMutation({
+function useCreateBankOperation() {
+ const options = useBankMutationOptions();
+
+ return useMutation({
+ ...options,
+ mutationFn: (body: CreateBankOperation) =>
+ mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
+ });
+}
+
+function useCreateExpenseCategory() {
+ const options = useBankMutationOptions();
+
+ return useMutation({
+ ...options,
+ mutationFn: (body: CreateExpenseCategory) =>
+ mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
+ });
+}
+
+function useDeleteBankOperation() {
+ const options = useBankMutationOptions();
+
+ return useMutation({
+ ...options,
+ mutationFn: ({ id, version }: { id: string; version: number }) =>
+ mutationData(
+ apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
+ params: { path: { id }, query: { version } },
+ }),
+ ),
+ });
+}
+
+function useUpdateBankCard() {
+ const options = useBankMutationOptions();
+
+ return useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData(
@@ -77,14 +119,12 @@ function useBankMutations() {
}),
),
});
+}
- const operationCreation = useMutation({
- ...options,
- mutationFn: (body: CreateBankOperation) =>
- mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
- });
+function useUpdateBankOperation() {
+ const options = useBankMutationOptions();
- const updateOperation = useMutation({
+ return useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData(
@@ -94,24 +134,12 @@ function useBankMutations() {
}),
),
});
+}
- const operationDeletion = useMutation({
- ...options,
- mutationFn: ({ id, version }: { id: string; version: number }) =>
- mutationData(
- apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
- params: { path: { id }, query: { version } },
- }),
- ),
- });
+function useUpdateExpenseCategory() {
+ const options = useBankMutationOptions();
- const categoryCreation = useMutation({
- ...options,
- mutationFn: (body: CreateExpenseCategory) =>
- mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
- });
-
- const updateCategory = useMutation({
+ return useMutation({
...options,
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData(
@@ -121,16 +149,14 @@ function useBankMutations() {
}),
),
});
-
- return {
- createCard: cardCreation,
- createCategory: categoryCreation,
- createOperation: operationCreation,
- deleteOperation: operationDeletion,
- updateCard,
- updateCategory,
- updateOperation,
- };
}
-export { useBankMutations };
+export {
+ useCreateBankCard,
+ useCreateBankOperation,
+ useCreateExpenseCategory,
+ useDeleteBankOperation,
+ useUpdateBankCard,
+ useUpdateBankOperation,
+ useUpdateExpenseCategory,
+};
diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts
index ee2aca1..9714f53 100644
--- a/src/entities/bank-card/index.ts
+++ b/src/entities/bank-card/index.ts
@@ -1,4 +1,9 @@
-export { useBankMutations } from "./api/mutations";
+export {
+ useCreateBankOperation,
+ useDeleteBankOperation,
+ useUpdateBankCard,
+ useUpdateBankOperation,
+} from "./api/mutations";
export {
type BankCard,
type BankOperation,
diff --git a/src/entities/bank-card/model/operation-appearance.test.ts b/src/entities/bank-card/model/operation-appearance.test.ts
new file mode 100644
index 0000000..b9d2867
--- /dev/null
+++ b/src/entities/bank-card/model/operation-appearance.test.ts
@@ -0,0 +1,54 @@
+import { expect, it } from "vitest";
+
+import { bankOperationAppearance } from "./operation-appearance";
+
+it.each(["Жильё", " ЖИЛЬЕ "])("normalizes category %s", (name) => {
+ expect(bankOperationAppearance("expense", name)).toEqual({
+ color: "orange", variant: "home",
+ });
+});
+
+it.each(["Другое", "Моя категория", "constructor", null, undefined])(
+ "uses the fallback for category %s",
+ (name) => {
+ expect(bankOperationAppearance("expense", name)).toEqual({
+ color: "gray", variant: "grid",
+ });
+ },
+);
+
+it.each([
+ ["topup", "green", "topup"],
+ ["transfer", "blue", "transfer"],
+ ["refund", "teal", "refund"],
+] as const)("ignores expense category for %s", (kind, color, variant) => {
+ expect(bankOperationAppearance(kind, "Жильё")).toEqual({ color, variant });
+});
+
+it.each([
+ ["Алкоголь", "wine"],
+ ["Вклады", "deposit"],
+ ["Жильё", "home"],
+ ["Здоровье", "medical"],
+ ["Инвестиции и накопления", "growth"],
+ ["Кафе", "coffee"],
+ ["Образование", "education"],
+ ["Переводы", "transfer"],
+ ["Погашение кредита", "repayment"],
+ ["Подписки", "subscriptions"],
+ ["Покупки", "shopping"],
+ ["Продукты", "basket"],
+ ["Путешествия", "travel"],
+ ["Развлечения", "music"],
+ ["Связь и интернет", "wifi"],
+ ["Снятие наличных", "cashWithdrawal"],
+ ["Ставки", "dice"],
+ ["Страхование", "shield"],
+ ["Табак", "smoking"],
+ ["Такси", "taxi"],
+ ["Транспорт", "car"],
+ ["Хостинг и ИИ-сервисы", "server"],
+ ["Цифровые покупки", "download"],
+])("maps category %s to its icon", (name, variant) => {
+ expect(bankOperationAppearance("expense", name).variant).toBe(variant);
+});
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
index eb06a71..53bffc1 100644
--- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
@@ -3,7 +3,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { useBankMutations } from "../../api/mutations";
+import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
@@ -20,7 +20,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
const [error, setError] = useState();
const [latest, setLatest] = useState();
const fresh = useBankCard(props.card?.id);
- const mutation = useBankMutations();
+ const cardCreation = useCreateBankCard();
+ const updateCard = useUpdateBankCard();
const methods = useForm({
defaultValues: {
@@ -68,7 +69,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
};
if (props.card)
- await mutation.updateCard.mutateAsync({
+ await updateCard.mutateAsync({
body: {
...metadata,
...(!props.card.archivedAt && financial),
@@ -77,7 +78,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
id: props.card.id,
});
else
- await mutation.createCard.mutateAsync({
+ await cardCreation.mutateAsync({
id: intent,
...metadata,
...financial,
diff --git a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx
deleted file mode 100644
index baf919c..0000000
--- a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { render } from "@testing-library/react";
-import { OperationIcon } from "@wealthledger/icons";
-import { expect, it } from "vitest";
-
-import { bankOperationAppearance } from "../../model/operation-appearance";
-
-const BankOperationIcon = (props: {
- categoryName?: null | string;
- kind: "expense" | "refund" | "topup" | "transfer";
-}) => (
-
-);
-
-it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
- const { container, rerender } = render(
- ,
- );
-
- // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- const path = () => container.querySelector("path")?.getAttribute("d");
- const housing = path();
- rerender();
- expect(path()).toBe(housing);
- rerender();
- const fallback = path();
- expect(fallback).not.toBe(housing);
-
- for (const categoryName of [
- "Моя категория",
- "constructor",
- null,
- ]) {
- rerender();
- expect(path()).toBe(fallback);
- }
-
- const paths = [fallback, housing];
-
- for (const kind of ["topup", "transfer", "refund"] as const) {
- rerender();
- expect(path()).toBeTruthy();
- paths.push(path());
- }
-
- expect(new Set(paths).size).toBe(5);
- // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
-});
-
-it("provides a distinct icon for every existing named category", () => {
- const { container, rerender } = render(
- ,
- );
-
- // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
-
- for (const categoryName of [
- "Алкоголь",
- "Вклады",
- "Жильё",
- "Здоровье",
- "Инвестиции и накопления",
- "Кафе",
- "Образование",
- "Переводы",
- "Погашение кредита",
- "Подписки",
- "Покупки",
- "Продукты",
- "Путешествия",
- "Развлечения",
- "Связь и интернет",
- "Снятие наличных",
- "Ставки",
- "Страхование",
- "Табак",
- "Такси",
- "Транспорт",
- "Хостинг и ИИ-сервисы",
- "Цифровые покупки",
- ]) {
- rerender();
- // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- const path = container.querySelector("path")?.getAttribute("d");
- expect(path).toBeTruthy();
- expect(paths.has(path), categoryName).toBe(false);
- paths.add(path);
- }
-});
diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts
index 2fa967d..ffedbec 100644
--- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts
+++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts
@@ -5,7 +5,10 @@ import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { useBankMutations } from "../../api/mutations";
+import {
+ useCreateExpenseCategory,
+ useUpdateExpenseCategory,
+} from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus";
@@ -22,7 +25,8 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const categories = useBankCategories();
- const mutation = useBankMutations();
+ const categoryCreation = useCreateExpenseCategory();
+ const updateCategory = useUpdateExpenseCategory();
const [editing, setEditing] = useState();
const editSessionRef = useRef(0);
const [error, setError] = useState();
@@ -43,12 +47,12 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
try {
if (editing)
- await mutation.updateCategory.mutateAsync({
+ await updateCategory.mutateAsync({
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
- const result = await mutation.createCategory.mutateAsync({
+ const result = await categoryCreation.mutateAsync({
name: values.name,
});
diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts
index 5651582..850e209 100644
--- a/src/features/tbank-connection/index.ts
+++ b/src/features/tbank-connection/index.ts
@@ -1,7 +1,2 @@
export { useScreenSync } from "./model/screen-sync";
-export {
- type InvestmentScreen,
- useTbankSync,
- useTbankSyncStatus,
-} from "./model/sync";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx
index d3ede0a..b284931 100644
--- a/src/features/tbank-connection/model/sync.test.tsx
+++ b/src/features/tbank-connection/model/sync.test.tsx
@@ -2,16 +2,15 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
+import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
-import {
- type InvestmentScreen,
- useTbankSync,
- useTbankSyncStatus,
-} from "../index";
+import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
+import { type InvestmentScreen, useTbankSync } from "./sync";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
@@ -37,6 +36,11 @@ describe("sync connection and session boundaries", () => {
let started = 0;
let isChanged = false;
server.use(
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json(resource([], {
+ sync: { errorCode: null, id: "work", state: "running" },
+ })),
+ ),
http.post("*/api/v1/tbank-sync", async () => {
started += 1;
await blocked;
@@ -76,10 +80,12 @@ describe("sync connection and session boundaries", () => {
});
const { result } = renderHook(
- () => ({
- mutation: useTbankSync(),
- status: useTbankSyncStatus("work"),
- }),
+ () => {
+ const accounts = useBrokerageAccounts();
+ useScreenSync({ screen: "accounts" }, [accounts]);
+
+ return { mutation: useTbankSync() };
+ },
{ wrapper },
);
diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts
index c53e2f6..fadb494 100644
--- a/src/features/tbank-connection/model/sync.ts
+++ b/src/features/tbank-connection/model/sync.ts
@@ -1,8 +1,4 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths } from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
@@ -13,14 +9,4 @@ function useTbankSync() {
});
}
-function useTbankSyncStatus(id: null | string) {
- return useAuthenticatedQuery({
- ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
- params: { path: { id: id ?? "" } },
- }),
- enabled: Boolean(id),
- retry: false,
- });
-}
-
-export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
+export { type InvestmentScreen, useTbankSync };
diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
index 7bb0fe7..1492a67 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -102,6 +102,24 @@ const portfolio: Portfolio
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
};
+function position(
+ overrides: Partial,
+): Portfolio["positions"][number] {
+ return {
+ absoluteReturn: null,
+ averagePurchasePrice: null,
+ currentValue: null,
+ displayName: "Инструмент",
+ figi: null,
+ isin: null,
+ logoName: null,
+ quantity: "1",
+ ticker: null,
+ type: "STOCK",
+ ...overrides,
+ };
+}
+
const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty",
to: "/accounts/account-1/operations",
@@ -474,242 +492,106 @@ describe("AccountPage states", () => {
);
});
- it("renders stock cards with logo fallback and signed unlabeled return", () => {
- renderPage(
- {
+ renderPage(
+ ,
- );
- expect(screen.getByText("А")).toBeInTheDocument();
- expect(screen.getByText("+3 ₽")).toBeInTheDocument();
- expect(screen.getByText("0 ₽")).toBeInTheDocument();
- expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
- expect(screen.queryByRole("img", { name: "Абра" })).not.toBeInTheDocument();
- });
+ })}
+ />,
+ );
+ expect(screen.getByRole("img", { name: "С логотипом" })).toHaveAttribute(
+ "src", "https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
+ );
+ expect(screen.getByText("Б")).toBeInTheDocument();
+ expect(screen.getByText("Н")).toBeInTheDocument();
+ expect(screen.getAllByRole("img")).toHaveLength(1);
+ for (const value of ["+3 ₽", "+2 ₽", "0 ₽"])
+ expect(screen.getByText(value)).toBeInTheDocument();
+ expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
+ },
+ );
- it("groups stock supporting metrics into quiet single lines", () => {
- renderPage(
- {
+ renderPage(
+ ,
- );
-
- expect(screen.getByText("Количество: 2 · RU000QUIET1")).not.toHaveAttribute(
- "data-line-clamp",
- );
- expect(
- screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"),
- ).toBeInTheDocument();
- });
+ positions: [position({
+ averagePurchasePrice: { currency: "RUB", value: "11" },
+ currentValue: { currency: "RUB", value: "10" },
+ isin: "RU000QUIET1",
+ quantity: "2",
+ type,
+ })],
+ })}
+ />,
+ );
+ expect(screen.getByText("Количество: 2 · RU000QUIET1"))
+ .not.toHaveAttribute("data-line-clamp");
+ expect(screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"))
+ .toBeInTheDocument();
+ },
+ );
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
,
);
-
expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument();
expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument();
- expect(
- screen.getByText("Количество: 2 · FALLBACK-ISIN"),
- ).toBeInTheDocument();
- expect(screen.getByText("Количество: 3 · BOND-ISIN")).toBeInTheDocument();
+ expect(screen.getByText("Количество: 2 · FALLBACK-ISIN"))
+ .toBeInTheDocument();
+ expect(screen.getByText("Количество: 3 · BOND-ISIN"))
+ .toBeInTheDocument();
expect(screen.queryByText("OFZ")).not.toBeInTheDocument();
});
- it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
- renderPage(
- ,
- );
- expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
- "src",
- "https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
- );
- expect(screen.getByText("К")).toBeInTheDocument();
- expect(
- screen.queryByRole("img", { name: "Купон" }),
- ).not.toBeInTheDocument();
- expect(screen.getByText("+2 ₽")).toBeInTheDocument();
- expect(screen.getByText("0 ₽")).toBeInTheDocument();
- expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
- });
-
- it("renders bond cards in expanded layout with bond-specific classes", () => {
- renderPage(
- ,
- );
- expect(screen.getByText("О")).toBeInTheDocument();
- expect(screen.getByText("+50 ₽")).toBeInTheDocument();
- expect(
- screen.getByText("Средняя цена: 950 ₽ · Доля: 100 %"),
- ).toBeInTheDocument();
- });
-
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
index 362a961..5378658 100644
--- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
+++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
@@ -29,7 +29,6 @@ import {
import type { BondsPageVm } from "./BondsPage.vm";
import classes from "./BondsPage.module.css";
-import { useBondsPageViewVm } from "./BondsPageView.vm";
type BondsPageViewProps = { vm: BondsPageVm };
@@ -166,7 +165,8 @@ const BondsPageViewResults: FC = (props) => {
};
const BondsPageView: FC = (props) => {
- const { onChange, t, vm } = useBondsPageViewVm(props);
+ const { vm } = props;
+ const { t } = useTranslation();
return (
@@ -191,7 +191,7 @@ const BondsPageView: FC = (props) => {
) => {
+ const q = event.currentTarget.value;
+ setParameters(q ? { q } : {}, { replace: true });
+ },
partial: result.meta?.availability === "partial",
query,
ready:
@@ -45,7 +51,6 @@ function useBondsPageVm() {
retry: () => {
void sync.retry();
},
- setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
};
}
diff --git a/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts
deleted file mode 100644
index 6871bb2..0000000
--- a/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ComponentProps } from "react";
-
-import { TextInput } from "@mantine/core";
-import { useTranslation } from "react-i18next";
-
-import type { BondsPageVm } from "./BondsPage.vm";
-
-type BondsPageViewProps = { vm: BondsPageVm };
-
-function useBondsPageViewVm(props: BondsPageViewProps) {
- const { vm } = props;
- const { t } = useTranslation();
-
- const onChange: ComponentProps["onChange"] = event =>
- vm.setQuery(event.currentTarget.value);
-
- return { onChange, t, vm };
-}
-
-export { useBondsPageViewVm };
diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx
index 9d23616..540b536 100644
--- a/src/pages/card/ui/CardPage/CardPage.test.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.test.tsx
@@ -126,6 +126,32 @@ function setup(
}
describe("CardPage", () => {
+ it.each([
+ ["Изменить карту", "Изменить карту"],
+ ["Добавить операцию", "Добавить операцию"],
+ ["Изменить", "Изменить операцию"],
+ ["Удалить", "Удалить операцию?"],
+ ])("opens %s and restores focus after cancellation", async (action, title) => {
+ setup();
+ server.use(
+ http.get("*/api/v1/bank-cards/categories", () =>
+ HttpResponse.json([]),
+ ),
+ http.get("*/api/v1/bank-cards/operations/:id", ({ params }) =>
+ HttpResponse.json({ ...operation, id: params.id }),
+ ),
+ );
+ await screen.findByText("Перевод на «Запасная»");
+ const trigger = screen.getByRole("button", { name: action });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ trigger.focus();
+ fireEvent.click(trigger);
+ const dialog = await screen.findByRole("dialog", { name: title });
+ fireEvent.click(within(dialog).getByRole("button", { name: "Отмена" }));
+ await waitFor(() => expect(dialog).not.toBeInTheDocument());
+ await waitFor(() => expect(trigger).toHaveFocus());
+ });
+
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
diff --git a/src/pages/card/ui/CardPage/CardPage.tsx b/src/pages/card/ui/CardPage/CardPage.tsx
index 10552dc..ea44bc3 100644
--- a/src/pages/card/ui/CardPage/CardPage.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.tsx
@@ -14,14 +14,14 @@ const CardPage: FC = () => {
<>
- {vm.editingCard && vm.card.data && (
-
+ {vm.editingCard && vm.card && (
+
)}
- {vm.operation && vm.card.data && vm.cards.data && (
+ {vm.operation && vm.card && vm.cards && (
diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx
index 56e9a81..24cd872 100644
--- a/src/pages/card/ui/CardPage/CardPage.view.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.view.tsx
@@ -41,7 +41,7 @@ type CardPageViewProps = { vm: CardPageVm };
const CardPageViewHistory: FC = (props) => {
const { vm } = props;
const { t } = useTranslation();
- const card = vm.card.data;
+ const card = vm.card;
if (!card) return;
@@ -70,11 +70,7 @@ const CardPageViewHistory: FC = (props) => {
= (props) => {
= (props) => {
return {t("bank.noOperations")};
}
- if (vm.history.isLoading) {
+ if (vm.history.loading) {
return (
@@ -142,24 +134,18 @@ const CardPageView: FC = (props) => {
const { vm } = props;
const {
- archiveActionLabel,
- card,
getControlProps,
getItemProps,
- onAddOperation,
- onEditCard,
onPageChange,
- onToggleArchive,
paginationRef,
- retryCard,
- retryCards,
- retryHistory,
- retrySummary,
- t,
} = useCardPagePresentationVm(vm);
+ const { t } = useTranslation();
+ const card = vm.card;
+ const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
+
if (!card) {
- if (vm.card.isLoading)
+ if (vm.cardLoading)
return (
@@ -184,7 +170,7 @@ const CardPageView: FC = (props) => {
{t("bank.back")}
- {!vm.notFound && }
+ {!vm.notFound && }
)}
error
@@ -207,7 +193,7 @@ const CardPageView: FC = (props) => {
= (props) => {
aria-label={archiveActionLabel}
color="red"
loading={vm.archivePending}
- onClick={onToggleArchive}
+ onClick={vm.toggleArchive}
radius="md"
size="md"
title={archiveActionLabel}
@@ -234,40 +220,33 @@ const CardPageView: FC = (props) => {
{!!vm.archiveError && (
-
+
)}
{card.archivedAt && {t("bank.archiveHint")}}
- {vm.card.isError && (
+ {vm.cardRefreshError && (
{t("bank.refreshError")}
-
+
)}
- {vm.card.isFetching && {t("bank.refreshing")}}
+ {vm.cardRefreshing && {t("bank.refreshing")}}
- {vm.cards.isError && (
+ {vm.cardsFeedback && (
)}
-
+
@@ -275,8 +254,8 @@ const CardPageView: FC = (props) => {
= (props) => {
- {vm.history.isError && (
+ {vm.history.error && (
{t(vm.rows ? "bank.refreshError" : "bank.error")}
-
+
)}
- {vm.history.data && vm.pageSize !== "all" && (
+ {vm.history.page !== undefined && vm.pageSize !== "all" && (
= (props) => {
ref={paginationRef}
>
();
const cards = useBankCards(true);
@@ -69,7 +69,9 @@ function useCardPageVm() {
return {
color: isOutgoing ? "red" : "green",
editable:
- Boolean(cards.data)
+ !history.isPlaceholderData
+ && !card.data?.archivedAt
+ && Boolean(cards.data)
&& !cards.data?.some(
c =>
(c.id === item.cardId || c.id === item.targetCardId)
@@ -94,27 +96,42 @@ function useCardPageVm() {
return {
archiveError,
archivePending: updateCard.isPending,
- card,
- cards,
+ canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
+ card: card.data,
+ cardLoading: card.isLoading,
+ cardRefreshError: card.isError,
+ cardRefreshing: card.isFetching,
+ cards: cards.data,
+ cardsFeedback: cards.isError
+ ? { cached: Boolean(cards.data), error: cards.error }
+ : undefined,
closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null),
deleting,
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
editingCard,
- history,
+ history: {
+ changingPage: history.isPlaceholderData,
+ error: history.isError,
+ loading: history.isLoading,
+ page: history.data?.page,
+ refreshing: history.isFetching,
+ retry: () => void history.refetch(),
+ },
notFound: isApiErrorCode(
apiErrorCode(card.error),
ApiErrorCode.BankNotFound,
),
+ openCardEditor: () => setEditingCard(true),
+ openNewOperation: () => setOperation("new"),
operation,
page,
pageSize,
period,
+ retryCard: () => void card.refetch(),
+ retryCards: () => void cards.refetch(),
rows,
- setDeleting,
- setEditingCard,
- setOperation,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
setPageSize: (value: null | string) => {
@@ -125,7 +142,13 @@ function useCardPageVm() {
if (value[0] && value[1])
setParameters({ from: value[0], limit: pageSize, to: value[1] });
},
- summary,
+ summary: {
+ data: summary.data,
+ error: summary.isError,
+ fetching: summary.isFetching,
+ loading: summary.isLoading,
+ retry: () => void summary.refetch(),
+ },
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
diff --git a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
index ee1b7ad..4fa4e68 100644
--- a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
+++ b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
@@ -8,7 +8,7 @@ import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) {
const { t } = useTranslation();
- const card = vm.card.data;
+ const card = vm.card;
const paginationRef = useRef(null);
const pendingPageRef = useRef vm.setEditingCard(true);
- const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
- const onToggleArchive = () => void vm.toggleArchive();
- const retryCard = () => void vm.card.refetch();
- const retryCards = () => void vm.cards.refetch();
- const retrySummary = () => void vm.summary.refetch();
- const onAddOperation = () => vm.setOperation("new");
- const retryHistory = () => void vm.history.refetch();
const getControlProps: ComponentProps<
typeof Pagination
@@ -77,20 +67,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
};
return {
- archiveActionLabel,
- card,
getControlProps,
getItemProps,
- onAddOperation,
- onEditCard,
onPageChange,
- onToggleArchive,
paginationRef,
- retryCard,
- retryCards,
- retryHistory,
- retrySummary,
- t,
};
}
diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
index ff76f02..d456231 100644
--- a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
+++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankFormFocus,
- useBankMutations,
useBankOperation,
+ useDeleteBankOperation,
} from "@/entities/bank-card";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
@@ -18,7 +18,7 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
const [latest, setLatest] = useState();
const [error, setError] = useState();
const query = useBankOperation(record.id);
- const { deleteOperation: operationDeletion } = useBankMutations();
+ const operationDeletion = useDeleteBankOperation();
const isPending
= !opened
diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
index 24be589..a8eb1da 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts
+++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
@@ -13,8 +13,9 @@ import {
useBankCategories,
useBankExpenses,
useBankFormFocus,
- useBankMutations,
useBankOperation,
+ useCreateBankOperation,
+ useUpdateBankOperation,
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
@@ -49,7 +50,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState();
const [error, setError] = useState();
const categories = useBankCategories();
- const mutation = useBankMutations();
+ const operationCreation = useCreateBankOperation();
+ const updateOperation = useUpdateBankOperation();
const methods = useForm({
defaultValues: {
@@ -116,12 +118,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
};
if (props.operation)
- await mutation.updateOperation.mutateAsync({
+ await updateOperation.mutateAsync({
body: { ...body, version },
id: props.operation.id,
});
else
- await mutation.createOperation.mutateAsync({
+ await operationCreation.mutateAsync({
id: intent,
...body,
});
diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
index 1c0f2d3..edcb503 100644
--- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
+++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
@@ -24,7 +24,6 @@ import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { StocksPageVm } from "./StocksPage.vm";
import classes from "./StocksPage.module.css";
-import { useStocksPageViewVm } from "./StocksPageView.vm";
type StocksPageViewProps = { vm: StocksPageVm };
@@ -139,7 +138,8 @@ const StocksPageViewResults: FC = (props) => {
};
const StocksPageView: FC = (props) => {
- const { onChange, t, vm } = useStocksPageViewVm(props);
+ const { vm } = props;
+ const { t } = useTranslation();
return (
@@ -164,7 +164,7 @@ const StocksPageView: FC = (props) => {
) => {
+ const q = event.currentTarget.value;
+ setParameters(q ? { q } : {}, { replace: true });
+ },
partial: result.meta?.availability === "partial",
query,
ready:
@@ -45,7 +51,6 @@ function useStocksPageVm() {
retry: () => {
void sync.retry();
},
- setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
};
}
diff --git a/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts
deleted file mode 100644
index 5fe7be9..0000000
--- a/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ComponentProps } from "react";
-
-import { TextInput } from "@mantine/core";
-import { useTranslation } from "react-i18next";
-
-import type { StocksPageVm } from "./StocksPage.vm";
-
-type StocksPageViewProps = { vm: StocksPageVm };
-
-function useStocksPageViewVm(props: StocksPageViewProps) {
- const { vm } = props;
- const { t } = useTranslation();
-
- const onChange: ComponentProps["onChange"] = event =>
- vm.setQuery(event.currentTarget.value);
-
- return { onChange, t, vm };
-}
-
-export { useStocksPageViewVm };