refactor: simplify frontend state and mutation boundaries #44
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
Reference in New Issue
Block a user