refactor: simplify frontend state and mutation boundaries #44

Merged
ksv741 merged 1 commits from codex/frontend-simplify-boundaries into develop 2026-09-28 20:12:39 +03:00
25 changed files with 414 additions and 547 deletions
+13 -1
View File
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])( it.each([AddIcon, EditIcon, DeleteIcon])(
@@ -34,4 +34,16 @@ describe("icon accessibility", () => {
expect(icon).toHaveAttribute("stroke", "currentColor"); expect(icon).toHaveAttribute("stroke", "currentColor");
}, },
); );
it("keeps operation icons decorative", () => {
render(
<button aria-label="Операция" type="button">
<OperationIcon color="orange" variant="home" />
</button>,
);
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",
);
});
}); });
+25 -17
View File
@@ -6,7 +6,13 @@ import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBankMutations } from "./mutations"; import {
useCreateBankOperation,
useDeleteBankOperation,
useUpdateBankCard,
useUpdateBankOperation,
useUpdateExpenseCategory,
} from "./mutations";
import { import {
type BankCard, type BankCard,
type CreateBankOperation, type CreateBankOperation,
@@ -111,7 +117,7 @@ function useReadModels() {
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
mutations: useBankMutations(), createOperation: useCreateBankOperation(),
reads: [ reads: [
cards, cards,
a, a,
@@ -217,11 +223,11 @@ describe("banking API", () => {
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
expect(refresh).toHaveBeenCalledTimes(1); expect(refresh).toHaveBeenCalledTimes(1);
@@ -239,17 +245,17 @@ describe("banking API", () => {
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(async () => { await act(async () => {
await expect( await expect(
result.current.createOperation.mutateAsync(transfer), result.current.mutateAsync(transfer),
).rejects.toThrow(); ).rejects.toThrow();
}); });
expect(bodies).toEqual([transfer]); expect(bodies).toEqual([transfer]);
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
}); });
@@ -266,20 +272,20 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ mutations: useBankMutations(), query: useBankCard("a") }), () => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>; let saving: Promise<unknown>;
act(() => { act(() => {
saving = result.current.mutations.createOperation saving = result.current.createOperation
.mutateAsync(transfer) .mutateAsync(transfer)
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {}); .catch(() => {});
}); });
await waitFor(() => 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"); expect(result.current.query.data?.balance.value).toBe("100");
await act(async () => { await act(async () => {
@@ -313,7 +319,7 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
await act(() => await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer), result.current.createOperation.mutateAsync(transfer),
); );
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2); for (const count of counts.values()) expect(count).toBe(2);
@@ -336,14 +342,14 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }), () => ({ card: useBankCard("a"), updateCard: useUpdateBankCard() }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.card.data?.version).toBe(1)); await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => { await act(async () => {
await expect( await expect(
result.current.mutations.updateCard.mutateAsync({ result.current.updateCard.mutateAsync({
body: { name: "changed", version: 1 }, body: { name: "changed", version: 1 },
id: "a", id: "a",
}), }),
@@ -389,7 +395,9 @@ describe("banking API", () => {
const { result } = renderHook( const { result } = renderHook(
() => ({ () => ({
categories: useBankCategories(), categories: useBankCategories(),
mutations: useBankMutations(), deleteOperation: useDeleteBankOperation(),
updateCategory: useUpdateExpenseCategory(),
updateOperation: useUpdateBankOperation(),
}), }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -398,7 +406,7 @@ describe("banking API", () => {
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0].name).toBe("Food"),
); );
await act(() => await act(() =>
result.current.mutations.updateCategory.mutateAsync({ result.current.updateCategory.mutateAsync({
body: { name: "Groceries", version: 1 }, body: { name: "Groceries", version: 1 },
id: "food", id: "food",
}), }),
@@ -407,13 +415,13 @@ describe("banking API", () => {
expect(result.current.categories.data?.[0].name).toBe("Groceries"), expect(result.current.categories.data?.[0].name).toBe("Groceries"),
); );
await act(() => await act(() =>
result.current.mutations.updateOperation.mutateAsync({ result.current.updateOperation.mutateAsync({
body: { description: "corrected", version: 1 }, body: { description: "corrected", version: 1 },
id: "original", id: "original",
}), }),
); );
await act(() => await act(() =>
result.current.mutations.deleteOperation.mutateAsync({ result.current.deleteOperation.mutateAsync({
id: "original", id: "original",
version: 2, version: 2,
}), }),
+63 -37
View File
@@ -26,7 +26,7 @@ async function mutationData<T>(
return data; return data;
} }
function useBankMutations() { function useBankMutationOptions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
@@ -43,7 +43,7 @@ function useBankMutations() {
}, },
}); });
const options = { return {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
if ( if (
@@ -60,14 +60,56 @@ function useBankMutations() {
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
} as const; } as const;
}
const cardCreation = useMutation({ function useCreateBankCard() {
const options = useBankMutationOptions();
return useMutation({
...options, ...options,
mutationFn: (body: CreateBankCard) => mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })), 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, ...options,
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData( mutationData(
@@ -77,14 +119,12 @@ function useBankMutations() {
}), }),
), ),
}); });
}
const operationCreation = useMutation({ function useUpdateBankOperation() {
...options, const options = useBankMutationOptions();
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
const updateOperation = useMutation({ return useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData( mutationData(
@@ -94,24 +134,12 @@ function useBankMutations() {
}), }),
), ),
}); });
}
const operationDeletion = useMutation({ function useUpdateExpenseCategory() {
...options, const options = useBankMutationOptions();
mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData(
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
params: { path: { id }, query: { version } },
}),
),
});
const categoryCreation = useMutation({ return useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
const updateCategory = useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) => mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData( 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,
};
+6 -1
View File
@@ -1,4 +1,9 @@
export { useBankMutations } from "./api/mutations"; export {
useCreateBankOperation,
useDeleteBankOperation,
useUpdateBankCard,
useUpdateBankOperation,
} from "./api/mutations";
export { export {
type BankCard, type BankCard,
type BankOperation, type BankOperation,
@@ -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);
});
@@ -3,7 +3,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations"; import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries"; import { type BankCard, useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation"; import { bankMoneyValue, bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
@@ -20,7 +20,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>(); const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id); const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations(); const cardCreation = useCreateBankCard();
const updateCard = useUpdateBankCard();
const methods = useForm<BankCardValues>({ const methods = useForm<BankCardValues>({
defaultValues: { defaultValues: {
@@ -68,7 +69,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
}; };
if (props.card) if (props.card)
await mutation.updateCard.mutateAsync({ await updateCard.mutateAsync({
body: { body: {
...metadata, ...metadata,
...(!props.card.archivedAt && financial), ...(!props.card.archivedAt && financial),
@@ -77,7 +78,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
id: props.card.id, id: props.card.id,
}); });
else else
await mutation.createCard.mutateAsync({ await cardCreation.mutateAsync({
id: intent, id: intent,
...metadata, ...metadata,
...financial, ...financial,
@@ -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";
}) => (
<OperationIcon
{...bankOperationAppearance(props.kind, props.categoryName)}
/>
);
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Жильё" kind="expense" />,
);
// 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(<BankOperationIcon categoryName=" ЖИЛЬЕ " kind="expense" />);
expect(path()).toBe(housing);
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path();
expect(fallback).not.toBe(housing);
for (const categoryName of [
"Моя категория",
"constructor",
null,
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
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(
<BankOperationIcon categoryName="Другое" kind="expense" />,
);
// 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(<BankOperationIcon categoryName={categoryName} kind="expense" />);
// 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);
}
});
@@ -5,7 +5,10 @@ import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations"; import {
useCreateExpenseCategory,
useUpdateExpenseCategory,
} from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries"; import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import { categorySchema } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
@@ -22,7 +25,8 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const [opened, setOpened] = useState(true); const [opened, setOpened] = useState(true);
const { t } = useTranslation(); const { t } = useTranslation();
const categories = useBankCategories(); const categories = useBankCategories();
const mutation = useBankMutations(); const categoryCreation = useCreateExpenseCategory();
const updateCategory = useUpdateExpenseCategory();
const [editing, setEditing] = useState<ExpenseCategory>(); const [editing, setEditing] = useState<ExpenseCategory>();
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
@@ -43,12 +47,12 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
try { try {
if (editing) if (editing)
await mutation.updateCategory.mutateAsync({ await updateCategory.mutateAsync({
body: { name: values.name, version: editing.version }, body: { name: values.name, version: editing.version },
id: editing.id, id: editing.id,
}); });
else { else {
const result = await mutation.createCategory.mutateAsync({ const result = await categoryCreation.mutateAsync({
name: values.name, name: values.name,
}); });
-5
View File
@@ -1,7 +1,2 @@
export { useScreenSync } from "./model/screen-sync"; export { useScreenSync } from "./model/screen-sync";
export {
type InvestmentScreen,
useTbankSync,
useTbankSyncStatus,
} from "./model/sync";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -2,16 +2,15 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { import { useScreenSync } from "../index";
type InvestmentScreen,
useTbankSync,
useTbankSyncStatus,
} from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary"; import { commitTbankConnectionBoundary } from "./connection-boundary";
import { type InvestmentScreen, useTbankSync } from "./sync";
describe("sync connection and session boundaries", () => { describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])( it.each(["connection", "session"])(
@@ -37,6 +36,11 @@ describe("sync connection and session boundaries", () => {
let started = 0; let started = 0;
let isChanged = false; let isChanged = false;
server.use( 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 () => { http.post("*/api/v1/tbank-sync", async () => {
started += 1; started += 1;
await blocked; await blocked;
@@ -76,10 +80,12 @@ describe("sync connection and session boundaries", () => {
}); });
const { result } = renderHook( const { result } = renderHook(
() => ({ () => {
mutation: useTbankSync(), const accounts = useBrokerageAccounts();
status: useTbankSyncStatus("work"), useScreenSync({ screen: "accounts" }, [accounts]);
}),
return { mutation: useTbankSync() };
},
{ wrapper }, { wrapper },
); );
+2 -16
View File
@@ -1,8 +1,4 @@
import { import { apiQueryClient, type paths } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type InvestmentScreen type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; = paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
@@ -13,14 +9,4 @@ function useTbankSync() {
}); });
} }
function useTbankSyncStatus(id: null | string) { export { type InvestmentScreen, useTbankSync };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id: id ?? "" } },
}),
enabled: Boolean(id),
retry: false,
});
}
export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
@@ -102,6 +102,24 @@ const portfolio: Portfolio
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
}; };
function position(
overrides: Partial<Portfolio["positions"][number]>,
): 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 = { const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty", state: "empty",
to: "/accounts/account-1/operations", to: "/accounts/account-1/operations",
@@ -474,242 +492,106 @@ describe("AccountPage states", () => {
); );
}); });
it("renders stock cards with logo fallback and signed unlabeled return", () => { it.each(["STOCK", "BOND"] as const)(
renderPage( "renders %s logos, fallbacks and signed returns including zero",
<AccountPageLoaded (type) => {
{...loadedProps( renderPage(
{ <AccountPageLoaded
{...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
{ position({
absoluteReturn: { currency: "RUB", value: "3" }, absoluteReturn: { currency: "RUB", value: "3" },
averagePurchasePrice: null, displayName: "С логотипом",
currentValue: { currency: "RUB", value: "5" }, logoName: "dolg.png",
displayName: "Абра", type,
figi: "FIGI-A", }),
isin: null, position({
logoName: null, absoluteReturn: { currency: "RUB", value: "2" },
quantity: "1", displayName: "Без логотипа",
ticker: null, type,
type: "STOCK", }),
}, position({
{
absoluteReturn: { currency: "RUB", value: "0" }, absoluteReturn: { currency: "RUB", value: "0" },
averagePurchasePrice: null, displayName: "Нулевой доход",
currentValue: { currency: "RUB", value: "7" }, type,
displayName: "Кадабра", }),
figi: "FIGI-B",
isin: null,
logoName: null,
quantity: "1",
ticker: null,
type: "STOCK",
},
], ],
}, })}
)} />,
/>, );
); expect(screen.getByRole("img", { name: "С логотипом" })).toHaveAttribute(
expect(screen.getByText("А")).toBeInTheDocument(); "src", "https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
expect(screen.getByText("+3 ₽")).toBeInTheDocument(); );
expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.getByText("Б")).toBeInTheDocument();
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); expect(screen.getByText("Н")).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Абра" })).not.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", () => { it.each(["STOCK", "BOND"] as const)(
renderPage( "groups %s supporting metrics into quiet single lines",
<AccountPageLoaded (type) => {
{...loadedProps( renderPage(
{ <AccountPageLoaded
{...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [position({
{ averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "3" }, currentValue: { currency: "RUB", value: "10" },
averagePurchasePrice: { currency: "RUB", value: "11" }, isin: "RU000QUIET1",
currentValue: { currency: "RUB", value: "10" }, quantity: "2",
displayName: "Тихая акция", type,
figi: "FIGI-QUIET", })],
isin: "RU000QUIET1", })}
logoName: null, />,
quantity: "2", );
ticker: null, expect(screen.getByText("Количество: 2 · RU000QUIET1"))
type: "STOCK", .not.toHaveAttribute("data-line-clamp");
}, expect(screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"))
], .toBeInTheDocument();
}, },
)} );
/>,
);
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", () => { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
{...loadedProps( {...loadedProps({
{ ...portfolio,
...portfolio, positions: [
positions: [ position({ isin: "STOCK-ISIN", ticker: "SBER" }),
{ position({ isin: "FALLBACK-ISIN", quantity: "2" }),
absoluteReturn: null, position({
averagePurchasePrice: null, isin: "BOND-ISIN", quantity: "3", ticker: "OFZ", type: "BOND",
currentValue: null, }),
displayName: "Акция с тикером", ],
figi: "FIGI-STOCK", })}
isin: "STOCK-ISIN",
logoName: null,
quantity: "1",
ticker: "SBER",
type: "STOCK",
},
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Акция без тикера",
figi: "FIGI-FALLBACK",
isin: "FALLBACK-ISIN",
logoName: null,
quantity: "2",
ticker: null,
type: "STOCK",
},
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Облигация",
figi: "FIGI-BOND",
isin: "BOND-ISIN",
logoName: null,
quantity: "3",
ticker: "OFZ",
type: "BOND",
},
],
},
)}
/>, />,
); );
expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument(); expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument();
expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument(); expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument();
expect( expect(screen.getByText("Количество: 2 · FALLBACK-ISIN"))
screen.getByText("Количество: 2 · FALLBACK-ISIN"), .toBeInTheDocument();
).toBeInTheDocument(); expect(screen.getByText("Количество: 3 · BOND-ISIN"))
expect(screen.getByText("Количество: 3 · BOND-ISIN")).toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByText("OFZ")).not.toBeInTheDocument(); expect(screen.queryByText("OFZ")).not.toBeInTheDocument();
}); });
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
<AccountPageLoaded
{...loadedProps(
{
...portfolio,
positions: [
{
absoluteReturn: { currency: "RUB", value: "2" },
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "50" },
displayName: "Долг",
figi: "FIGI-C",
isin: "RU000C0XY1Z3",
logoName: "dolg.png",
quantity: "5",
ticker: null,
type: "BOND",
},
{
absoluteReturn: { currency: "RUB", value: "0" },
averagePurchasePrice: null,
currentValue: null,
displayName: "Купон",
figi: "FIGI-D",
isin: null,
logoName: null,
quantity: "1",
ticker: null,
type: "BOND",
},
],
},
)}
/>,
);
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(
<AccountPageLoaded
{...loadedProps(
{
...portfolio,
positions: [
{
absoluteReturn: { currency: "RUB", value: "50" },
averagePurchasePrice: { currency: "RUB", value: "950" },
currentValue: { currency: "RUB", value: "1000" },
displayName: "Облигация 2030",
figi: "FIGI-BOND",
isin: "RU000B0ND123",
logoName: null,
quantity: "10",
ticker: null,
type: "BOND",
},
],
},
)}
/>,
);
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", () => { it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
{...loadedProps( {...loadedProps({
{ ...portfolio,
...portfolio, positions: [position({
positions: [ absoluteReturn: { currency: "RUB", value: "-5" },
{ displayName: "Фонд",
absoluteReturn: { currency: "RUB", value: "-5" }, logoName: "fond.png",
averagePurchasePrice: null, type: "FUND",
currentValue: { currency: "RUB", value: "30" }, })],
displayName: "Фонд", })}
figi: "FIGI-E",
isin: null,
logoName: "fond.png",
quantity: "3",
ticker: null,
type: "FUND",
},
],
},
)}
/>, />,
); );
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -29,7 +29,6 @@ import {
import type { BondsPageVm } from "./BondsPage.vm"; import type { BondsPageVm } from "./BondsPage.vm";
import classes from "./BondsPage.module.css"; import classes from "./BondsPage.module.css";
import { useBondsPageViewVm } from "./BondsPageView.vm";
type BondsPageViewProps = { vm: BondsPageVm }; type BondsPageViewProps = { vm: BondsPageVm };
@@ -166,7 +165,8 @@ const BondsPageViewResults: FC<BondsPageViewProps> = (props) => {
}; };
const BondsPageView: FC<BondsPageViewProps> = (props) => { const BondsPageView: FC<BondsPageViewProps> = (props) => {
const { onChange, t, vm } = useBondsPageViewVm(props); const { vm } = props;
const { t } = useTranslation();
return ( return (
<Stack gap="lg"> <Stack gap="lg">
@@ -191,7 +191,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
<TextInput <TextInput
label={t("bond.searchLabel")} label={t("bond.searchLabel")}
maxLength={80} maxLength={80}
onChange={onChange} onChange={vm.onQueryChange}
placeholder={t("bond.searchPlaceholder")} placeholder={t("bond.searchPlaceholder")}
size="md" size="md"
value={vm.query} value={vm.query}
+6 -1
View File
@@ -1,3 +1,5 @@
import type { ChangeEvent } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
@@ -35,6 +37,10 @@ function useBondsPageVm() {
|| (!result.data || (!result.data
&& !result.isError && !result.isError
&& result.meta?.sync.state !== "failed")), && result.meta?.sync.state !== "failed")),
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
const q = event.currentTarget.value;
setParameters(q ? { q } : {}, { replace: true });
},
partial: result.meta?.availability === "partial", partial: result.meta?.availability === "partial",
query, query,
ready: ready:
@@ -45,7 +51,6 @@ function useBondsPageVm() {
retry: () => { retry: () => {
void sync.retry(); void sync.retry();
}, },
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
}; };
} }
@@ -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<typeof TextInput>["onChange"] = event =>
vm.setQuery(event.currentTarget.value);
return { onChange, t, vm };
}
export { useBondsPageViewVm };
@@ -126,6 +126,32 @@ function setup(
} }
describe("CardPage", () => { 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([ it.each([
["0001-01", "0001-01-31"], ["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"], ["0099-12", "0099-12-31"],
+5 -5
View File
@@ -14,14 +14,14 @@ const CardPage: FC = () => {
<> <>
<CardPageView vm={vm} /> <CardPageView vm={vm} />
{vm.editingCard && vm.card.data && ( {vm.editingCard && vm.card && (
<BankCardForm card={vm.card.data} onClose={vm.closeEditing} /> <BankCardForm card={vm.card} onClose={vm.closeEditing} />
)} )}
{vm.operation && vm.card.data && vm.cards.data && ( {vm.operation && vm.card && vm.cards && (
<OperationForm <OperationForm
card={vm.card.data} card={vm.card}
cards={vm.cards.data} cards={vm.cards}
onClose={vm.closeOperation} onClose={vm.closeOperation}
operation={vm.editableOperation} operation={vm.editableOperation}
/> />
+29 -50
View File
@@ -41,7 +41,7 @@ type CardPageViewProps = { vm: CardPageVm };
const CardPageViewHistory: FC<CardPageViewProps> = (props) => { const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const card = vm.card.data; const card = vm.card;
if (!card) return; if (!card) return;
@@ -70,11 +70,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<ActionIcon <ActionIcon
aria-label={t("bank.edit")} aria-label={t("bank.edit")}
disabled={ disabled={!row.editable}
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={row.onEdit} onClick={row.onEdit}
size="sm" size="sm"
title={t("bank.edit")} title={t("bank.edit")}
@@ -86,11 +82,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
<ActionIcon <ActionIcon
aria-label={t("bank.delete")} aria-label={t("bank.delete")}
color="red" color="red"
disabled={ disabled={!row.editable}
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={row.onDelete} onClick={row.onDelete}
size="sm" size="sm"
title={t("bank.delete")} title={t("bank.delete")}
@@ -125,7 +117,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
return <Text>{t("bank.noOperations")}</Text>; return <Text>{t("bank.noOperations")}</Text>;
} }
if (vm.history.isLoading) { if (vm.history.loading) {
return ( return (
<LoadingSkeleton label={t("bank.loadingHistory")}> <LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack> <Stack>
@@ -142,24 +134,18 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { const {
archiveActionLabel,
card,
getControlProps, getControlProps,
getItemProps, getItemProps,
onAddOperation,
onEditCard,
onPageChange, onPageChange,
onToggleArchive,
paginationRef, paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
} = useCardPagePresentationVm(vm); } = useCardPagePresentationVm(vm);
const { t } = useTranslation();
const card = vm.card;
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
if (!card) { if (!card) {
if (vm.card.isLoading) if (vm.cardLoading)
return ( return (
<LoadingSkeleton label={t("bank.loading")}> <LoadingSkeleton label={t("bank.loading")}>
<Stack> <Stack>
@@ -184,7 +170,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")} {t("bank.back")}
</Button> </Button>
{!vm.notFound && <Button onClick={retryCard}>{t("bank.retry")}</Button>} {!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group> </Group>
)} )}
error error
@@ -207,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<Group className={classes.cardActions} gap={4} wrap="nowrap"> <Group className={classes.cardActions} gap={4} wrap="nowrap">
<ActionIcon <ActionIcon
aria-label={t("bank.editCard")} aria-label={t("bank.editCard")}
onClick={onEditCard} onClick={vm.openCardEditor}
radius="md" radius="md"
size="md" size="md"
title={t("bank.editCard")} title={t("bank.editCard")}
@@ -220,7 +206,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
aria-label={archiveActionLabel} aria-label={archiveActionLabel}
color="red" color="red"
loading={vm.archivePending} loading={vm.archivePending}
onClick={onToggleArchive} onClick={vm.toggleArchive}
radius="md" radius="md"
size="md" size="md"
title={archiveActionLabel} title={archiveActionLabel}
@@ -234,40 +220,33 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div> </div>
{!!vm.archiveError && ( {!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={retryCard} /> <BankFeedback error={vm.archiveError} retry={vm.retryCard} />
)} )}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>} {card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack> </Stack>
{vm.card.isError && ( {vm.cardRefreshError && (
<Group role="alert"> <Group role="alert">
<Text>{t("bank.refreshError")}</Text> <Text>{t("bank.refreshError")}</Text>
<Button onClick={retryCard}>{t("bank.retry")}</Button> <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
</Group> </Group>
)} )}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>} {vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && ( {vm.cardsFeedback && (
<BankFeedback <BankFeedback
cached={Boolean(vm.cards.data)} {...vm.cardsFeedback}
error={vm.cards.error}
loadingError loadingError
retry={retryCards} retry={vm.retryCards}
/> />
)} )}
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} /> <BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView <BankSummaryView {...vm.summary} />
data={vm.summary.data}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={retrySummary}
/>
<Group align="end" justify="space-between"> <Group align="end" justify="space-between">
<Group gap="xs"> <Group gap="xs">
@@ -275,8 +254,8 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<ActionIcon <ActionIcon
aria-label={t("bank.addOperation")} aria-label={t("bank.addOperation")}
disabled={Boolean(card.archivedAt) || !vm.cards.data} disabled={!vm.canAddOperation}
onClick={onAddOperation} onClick={vm.openNewOperation}
size="lg" size="lg"
title={t("bank.addOperation")} title={t("bank.addOperation")}
variant="subtle" variant="subtle"
@@ -303,24 +282,24 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group> </Group>
<Stack <Stack
aria-busy={vm.history.isFetching} aria-busy={vm.history.refreshing}
className={classes.operations} className={classes.operations}
data-pending={vm.history.isPlaceholderData || undefined} data-pending={vm.history.changingPage || undefined}
gap="lg" gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`} key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
> >
<CardPageViewHistory {...props} /> <CardPageViewHistory {...props} />
</Stack> </Stack>
{vm.history.isError && ( {vm.history.error && (
<Group role="alert"> <Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text> <Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={retryHistory}>{t("bank.retry")}</Button> <Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group> </Group>
)} )}
{vm.history.data && vm.pageSize !== "all" && ( {vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group <Group
aria-label={t("bank.pagination")} aria-label={t("bank.pagination")}
component="nav" component="nav"
@@ -329,7 +308,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef} ref={paginationRef}
> >
<Pagination <Pagination
disabled={vm.history.isFetching} disabled={vm.history.refreshing}
getControlProps={getControlProps} getControlProps={getControlProps}
getItemProps={getItemProps} getItemProps={getItemProps}
onChange={onPageChange} onChange={onPageChange}
+33 -10
View File
@@ -7,9 +7,9 @@ import {
bankPeriod, bankPeriod,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankMutations,
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
useUpdateBankCard,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { formatDate, formatMoney } from "@/shared/lib"; import { formatDate, formatMoney } from "@/shared/lib";
@@ -33,7 +33,7 @@ function useCardPageVm() {
: 1; : 1;
const card = useBankCard(cardId); const card = useBankCard(cardId);
const { updateCard } = useBankMutations(); const updateCard = useUpdateBankCard();
const [archiveError, setArchiveError] = useState<unknown>(); const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true); const cards = useBankCards(true);
@@ -69,7 +69,9 @@ function useCardPageVm() {
return { return {
color: isOutgoing ? "red" : "green", color: isOutgoing ? "red" : "green",
editable: editable:
Boolean(cards.data) !history.isPlaceholderData
&& !card.data?.archivedAt
&& Boolean(cards.data)
&& !cards.data?.some( && !cards.data?.some(
c => c =>
(c.id === item.cardId || c.id === item.targetCardId) (c.id === item.cardId || c.id === item.targetCardId)
@@ -94,27 +96,42 @@ function useCardPageVm() {
return { return {
archiveError, archiveError,
archivePending: updateCard.isPending, archivePending: updateCard.isPending,
card, canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
cards, 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), closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false), closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null), closeOperation: () => setOperation(null),
deleting, deleting,
editableOperation: operation === "new" ? undefined : (operation ?? undefined), editableOperation: operation === "new" ? undefined : (operation ?? undefined),
editingCard, 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( notFound: isApiErrorCode(
apiErrorCode(card.error), apiErrorCode(card.error),
ApiErrorCode.BankNotFound, ApiErrorCode.BankNotFound,
), ),
openCardEditor: () => setEditingCard(true),
openNewOperation: () => setOperation("new"),
operation, operation,
page, page,
pageSize, pageSize,
period, period,
retryCard: () => void card.refetch(),
retryCards: () => void cards.refetch(),
rows, rows,
setDeleting,
setEditingCard,
setOperation,
setPage: (value: number) => setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }), setParameters({ ...period, limit: pageSize, page: String(value) }),
setPageSize: (value: null | string) => { setPageSize: (value: null | string) => {
@@ -125,7 +142,13 @@ function useCardPageVm() {
if (value[0] && value[1]) if (value[0] && value[1])
setParameters({ from: value[0], limit: pageSize, to: 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 () => { toggleArchive: async () => {
if (!card.data) return; if (!card.data) return;
setArchiveError(undefined); setArchiveError(undefined);
@@ -8,7 +8,7 @@ import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) { function useCardPagePresentationVm(vm: CardPageVm) {
const { t } = useTranslation(); const { t } = useTranslation();
const card = vm.card.data; const card = vm.card;
const paginationRef = useRef<HTMLDivElement>(null); const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null const pendingPageRef = useRef<null
@@ -23,15 +23,13 @@ function useCardPagePresentationVm(vm: CardPageVm) {
const pending = pendingPageRef.current; const pending = pendingPageRef.current;
if (!pending) return; if (!pending) return;
if (pending.scope !== scope || vm.history.isError) { if (pending.scope !== scope || vm.history.error) {
pendingPageRef.current pendingPageRef.current = null;
= null;
return; return;
} }
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page) if (vm.history.changingPage || vm.history.page !== pending.page)
return; return;
const pagination = paginationRef.current; const pagination = paginationRef.current;
@@ -43,18 +41,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
pendingPageRef.current = null; pendingPageRef.current = null;
}, [ }, [
scope, scope,
vm.history.data, vm.history.page,
vm.history.isPlaceholderData, vm.history.changingPage,
vm.history.isError, vm.history.error,
]); ]);
const onEditCard = () => 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< const getControlProps: ComponentProps<
typeof Pagination typeof Pagination
@@ -77,20 +67,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
}; };
return { return {
archiveActionLabel,
card,
getControlProps, getControlProps,
getItemProps, getItemProps,
onAddOperation,
onEditCard,
onPageChange, onPageChange,
onToggleArchive,
paginationRef, paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
}; };
} }
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import { import {
type BankOperation, type BankOperation,
useBankFormFocus, useBankFormFocus,
useBankMutations,
useBankOperation, useBankOperation,
useDeleteBankOperation,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation }; type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
@@ -18,7 +18,7 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
const [latest, setLatest] = useState<BankOperation>(); const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id); const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations(); const operationDeletion = useDeleteBankOperation();
const isPending const isPending
= !opened = !opened
@@ -13,8 +13,9 @@ import {
useBankCategories, useBankCategories,
useBankExpenses, useBankExpenses,
useBankFormFocus, useBankFormFocus,
useBankMutations,
useBankOperation, useBankOperation,
useCreateBankOperation,
useUpdateBankOperation,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib"; import { formatDate, formatMoney } from "@/shared/lib";
@@ -49,7 +50,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState<OperationValues>(); const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const categories = useBankCategories(); const categories = useBankCategories();
const mutation = useBankMutations(); const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation();
const methods = useForm<OperationValues>({ const methods = useForm<OperationValues>({
defaultValues: { defaultValues: {
@@ -116,12 +118,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
}; };
if (props.operation) if (props.operation)
await mutation.updateOperation.mutateAsync({ await updateOperation.mutateAsync({
body: { ...body, version }, body: { ...body, version },
id: props.operation.id, id: props.operation.id,
}); });
else else
await mutation.createOperation.mutateAsync({ await operationCreation.mutateAsync({
id: intent, id: intent,
...body, ...body,
}); });
@@ -24,7 +24,6 @@ import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { StocksPageVm } from "./StocksPage.vm"; import type { StocksPageVm } from "./StocksPage.vm";
import classes from "./StocksPage.module.css"; import classes from "./StocksPage.module.css";
import { useStocksPageViewVm } from "./StocksPageView.vm";
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
@@ -139,7 +138,8 @@ const StocksPageViewResults: FC<StocksPageViewProps> = (props) => {
}; };
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { onChange, t, vm } = useStocksPageViewVm(props); const { vm } = props;
const { t } = useTranslation();
return ( return (
<Stack gap="lg"> <Stack gap="lg">
@@ -164,7 +164,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
<TextInput <TextInput
label={t("stock.searchLabel")} label={t("stock.searchLabel")}
maxLength={80} maxLength={80}
onChange={onChange} onChange={vm.onQueryChange}
placeholder={t("stock.searchPlaceholder")} placeholder={t("stock.searchPlaceholder")}
size="md" size="md"
value={vm.query} value={vm.query}
@@ -1,3 +1,5 @@
import type { ChangeEvent } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
@@ -35,6 +37,10 @@ function useStocksPageVm() {
|| (!result.data || (!result.data
&& !result.isError && !result.isError
&& result.meta?.sync.state !== "failed")), && result.meta?.sync.state !== "failed")),
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
const q = event.currentTarget.value;
setParameters(q ? { q } : {}, { replace: true });
},
partial: result.meta?.availability === "partial", partial: result.meta?.availability === "partial",
query, query,
ready: ready:
@@ -45,7 +51,6 @@ function useStocksPageVm() {
retry: () => { retry: () => {
void sync.retry(); void sync.retry();
}, },
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
}; };
} }
@@ -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<typeof TextInput>["onChange"] = event =>
vm.setQuery(event.currentTarget.value);
return { onChange, t, vm };
}
export { useStocksPageViewVm };