refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
This commit was merged in pull request #44.
This commit is contained in:
@@ -6,7 +6,13 @@ import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { useBankMutations } from "./mutations";
|
||||
import {
|
||||
useCreateBankOperation,
|
||||
useDeleteBankOperation,
|
||||
useUpdateBankCard,
|
||||
useUpdateBankOperation,
|
||||
useUpdateExpenseCategory,
|
||||
} from "./mutations";
|
||||
import {
|
||||
type BankCard,
|
||||
type CreateBankOperation,
|
||||
@@ -111,7 +117,7 @@ function useReadModels() {
|
||||
const original = useBankOperation("original");
|
||||
|
||||
return {
|
||||
mutations: useBankMutations(),
|
||||
createOperation: useCreateBankOperation(),
|
||||
reads: [
|
||||
cards,
|
||||
a,
|
||||
@@ -217,11 +223,11 @@ describe("banking API", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
const { result } = renderHook(useCreateBankOperation, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||
await act(() => result.current.mutateAsync(transfer));
|
||||
expect(bodies).toEqual([transfer, transfer]);
|
||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
@@ -239,17 +245,17 @@ describe("banking API", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
const { result } = renderHook(useCreateBankOperation, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createOperation.mutateAsync(transfer),
|
||||
result.current.mutateAsync(transfer),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
expect(bodies).toEqual([transfer]);
|
||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||
await act(() => result.current.mutateAsync(transfer));
|
||||
expect(bodies).toEqual([transfer, transfer]);
|
||||
});
|
||||
|
||||
@@ -266,20 +272,20 @@ describe("banking API", () => {
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
||||
() => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
saving = result.current.mutations.createOperation
|
||||
saving = result.current.createOperation
|
||||
.mutateAsync(transfer)
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
.catch(() => {});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.mutations.createOperation.isPending).toBe(true),
|
||||
expect(result.current.createOperation.isPending).toBe(true),
|
||||
);
|
||||
expect(result.current.query.data?.balance.value).toBe("100");
|
||||
await act(async () => {
|
||||
@@ -313,7 +319,7 @@ describe("banking API", () => {
|
||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.createOperation.mutateAsync(transfer),
|
||||
result.current.createOperation.mutateAsync(transfer),
|
||||
);
|
||||
expect(counts.size).toBe(9);
|
||||
for (const count of counts.values()) expect(count).toBe(2);
|
||||
@@ -336,14 +342,14 @@ describe("banking API", () => {
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
||||
() => ({ card: useBankCard("a"), updateCard: useUpdateBankCard() }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.mutations.updateCard.mutateAsync({
|
||||
result.current.updateCard.mutateAsync({
|
||||
body: { name: "changed", version: 1 },
|
||||
id: "a",
|
||||
}),
|
||||
@@ -389,7 +395,9 @@ describe("banking API", () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
categories: useBankCategories(),
|
||||
mutations: useBankMutations(),
|
||||
deleteOperation: useDeleteBankOperation(),
|
||||
updateCategory: useUpdateExpenseCategory(),
|
||||
updateOperation: useUpdateBankOperation(),
|
||||
}),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
@@ -398,7 +406,7 @@ describe("banking API", () => {
|
||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.updateCategory.mutateAsync({
|
||||
result.current.updateCategory.mutateAsync({
|
||||
body: { name: "Groceries", version: 1 },
|
||||
id: "food",
|
||||
}),
|
||||
@@ -407,13 +415,13 @@ describe("banking API", () => {
|
||||
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.updateOperation.mutateAsync({
|
||||
result.current.updateOperation.mutateAsync({
|
||||
body: { description: "corrected", version: 1 },
|
||||
id: "original",
|
||||
}),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.deleteOperation.mutateAsync({
|
||||
result.current.deleteOperation.mutateAsync({
|
||||
id: "original",
|
||||
version: 2,
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,7 @@ async function mutationData<T>(
|
||||
return data;
|
||||
}
|
||||
|
||||
function useBankMutations() {
|
||||
function useBankMutationOptions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () =>
|
||||
@@ -43,7 +43,7 @@ function useBankMutations() {
|
||||
},
|
||||
});
|
||||
|
||||
const options = {
|
||||
return {
|
||||
onError: (error: unknown) => {
|
||||
const errorCode = apiErrorCode(error);
|
||||
if (
|
||||
@@ -60,14 +60,56 @@ function useBankMutations() {
|
||||
onSuccess: invalidate,
|
||||
retry: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const cardCreation = useMutation({
|
||||
function useCreateBankCard() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankCard) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
|
||||
});
|
||||
}
|
||||
|
||||
const updateCard = useMutation({
|
||||
function useCreateBankOperation() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankOperation) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
||||
});
|
||||
}
|
||||
|
||||
function useCreateExpenseCategory() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateExpenseCategory) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
||||
});
|
||||
}
|
||||
|
||||
function useDeleteBankOperation() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
||||
mutationData(
|
||||
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
|
||||
params: { path: { id }, query: { version } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateBankCard() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
|
||||
mutationData(
|
||||
@@ -77,14 +119,12 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const operationCreation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankOperation) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
||||
});
|
||||
function useUpdateBankOperation() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
const updateOperation = useMutation({
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
|
||||
mutationData(
|
||||
@@ -94,24 +134,12 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const operationDeletion = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
||||
mutationData(
|
||||
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
|
||||
params: { path: { id }, query: { version } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
function useUpdateExpenseCategory() {
|
||||
const options = useBankMutationOptions();
|
||||
|
||||
const categoryCreation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateExpenseCategory) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
||||
});
|
||||
|
||||
const updateCategory = useMutation({
|
||||
return useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
|
||||
mutationData(
|
||||
@@ -121,16 +149,14 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
createCard: cardCreation,
|
||||
createCategory: categoryCreation,
|
||||
createOperation: operationCreation,
|
||||
deleteOperation: operationDeletion,
|
||||
updateCard,
|
||||
updateCategory,
|
||||
updateOperation,
|
||||
};
|
||||
}
|
||||
|
||||
export { useBankMutations };
|
||||
export {
|
||||
useCreateBankCard,
|
||||
useCreateBankOperation,
|
||||
useCreateExpenseCategory,
|
||||
useDeleteBankOperation,
|
||||
useUpdateBankCard,
|
||||
useUpdateBankOperation,
|
||||
useUpdateExpenseCategory,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { useBankMutations } from "./api/mutations";
|
||||
export {
|
||||
useCreateBankOperation,
|
||||
useDeleteBankOperation,
|
||||
useUpdateBankCard,
|
||||
useUpdateBankOperation,
|
||||
} from "./api/mutations";
|
||||
export {
|
||||
type BankCard,
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { useBankMutations } from "../../api/mutations";
|
||||
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
|
||||
import { type BankCard, useBankCard } from "../../api/queries";
|
||||
import { bankMoneyValue, bankToday } from "../../model/form-validation";
|
||||
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
||||
@@ -20,7 +20,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<BankCard>();
|
||||
const fresh = useBankCard(props.card?.id);
|
||||
const mutation = useBankMutations();
|
||||
const cardCreation = useCreateBankCard();
|
||||
const updateCard = useUpdateBankCard();
|
||||
|
||||
const methods = useForm<BankCardValues>({
|
||||
defaultValues: {
|
||||
@@ -68,7 +69,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
||||
};
|
||||
|
||||
if (props.card)
|
||||
await mutation.updateCard.mutateAsync({
|
||||
await updateCard.mutateAsync({
|
||||
body: {
|
||||
...metadata,
|
||||
...(!props.card.archivedAt && financial),
|
||||
@@ -77,7 +78,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
||||
id: props.card.id,
|
||||
});
|
||||
else
|
||||
await mutation.createCard.mutateAsync({
|
||||
await cardCreation.mutateAsync({
|
||||
id: intent,
|
||||
...metadata,
|
||||
...financial,
|
||||
|
||||
@@ -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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { useBankMutations } from "../../api/mutations";
|
||||
import {
|
||||
useCreateExpenseCategory,
|
||||
useUpdateExpenseCategory,
|
||||
} from "../../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||
import { categorySchema } from "../../model/category.schema";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
@@ -22,7 +25,8 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
const [opened, setOpened] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const categoryCreation = useCreateExpenseCategory();
|
||||
const updateCategory = useUpdateExpenseCategory();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const editSessionRef = useRef(0);
|
||||
const [error, setError] = useState<unknown>();
|
||||
@@ -43,12 +47,12 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
await updateCategory.mutateAsync({
|
||||
body: { name: values.name, version: editing.version },
|
||||
id: editing.id,
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
const result = await categoryCreation.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user