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 };