refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -23,18 +23,23 @@ describe("CategoryManager", () => {
clear: () => {},
refresh: () => Promise.resolve(false),
});
const categories = [
{ id: "a", isDefault: false, name: "Первая", version: 1 },
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
];
let reads = 0;
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
const { promise: reloaded, resolve: release }
= Promise.withResolvers<void>();
const patches: { body: unknown; id: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map(category =>
category.id === "a" && reads > 1
@@ -51,6 +56,7 @@ describe("CategoryManager", () => {
"*/api/v1/bank-cards/categories/:id",
async ({ params, request }) => {
patches.push({ body: await request.json(), id: params.id });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -67,7 +73,11 @@ describe("CategoryManager", () => {
</MantineProvider>
</QueryClientProvider>,
);
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
const renameButtons = await screen.findAllByRole("button", {
name: "Переименовать",
});
fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
@@ -122,6 +132,7 @@ describe("CategoryManager", () => {
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -163,12 +174,14 @@ describe("CategoryManager", () => {
clear: () => {},
refresh: () => Promise.resolve(false),
});
let category = {
id: "default",
isDefault: true,
name: "Другое",
version: 1,
};
let creates = 0;
let body: unknown;
server.use(
@@ -177,6 +190,7 @@ describe("CategoryManager", () => {
),
http.post("*/api/v1/bank-cards/categories", () => {
creates++;
return HttpResponse.json({});
}),
http.patch(
@@ -184,6 +198,7 @@ describe("CategoryManager", () => {
async ({ request }) => {
body = await request.json();
category = { ...category, name: "Остальное", version: 2 };
return HttpResponse.json(category);
},
),
@@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Badge,
Button,
@@ -8,218 +7,148 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
useForm,
useFormContext,
} from "react-hook-form";
import { OperationIcon } from "@wealthledger/icons";
import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import { useBankFormFocus } from "../useBankFormFocus";
const MAX_CATEGORY_NAME_LENGTH = 80;
import { useCategoryManagerVm } from "./CategoryManager.vm";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => {
const { t } = useTranslation();
const {
control,
formState: { errors },
} = useFormContext<CategoryValues>();
const { field: nameField } = useController({ control, name: "name" });
return (
<Controller
control={control}
name="name"
render={({ field }) => (
<TextInput
label={t("bank.categoryName")}
required
{...field}
error={errors.name?.message}
/>
)}
<TextInput
label={t("bank.categoryName")}
required
{...nameField}
error={errors.name?.message}
/>
);
};
const CategoryManager: FC<CategoryManagerProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const schema = z.object({
name: z
.string()
.trim()
.refine(
value =>
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
{ error: validationMessage("bank.validation.category") },
)
.refine(
value =>
!categories.data?.some(
c =>
c.id !== editing?.id
&& c.name.toLocaleLowerCase("ru-RU")
=== value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
),
});
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
const isPending = !opened || methods.formState.isSubmitting;
const {
categories,
categoryRows,
editing,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onDismiss,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
retryCategories,
t,
} = useCategoryManagerVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t("bank.categories")}
>
<FormProvider {...methods}>
<form
onSubmit={(event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
try {
if (editing)
await mutation.updateCategory.mutateAsync({
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
const result = await mutation.createCategory.mutateAsync({
name: values.name,
});
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
}
catch (error_) {
setError(error_);
}
})(event);
}}
>
<form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
{categories.data?.map(category => (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<BankOperationIcon
categoryName={category.name}
kind="expense"
/>
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
{categoryRows?.map((category) => {
return (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<OperationIcon
{...bankOperationAppearance("expense", category.name)}
/>
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>
)}
</Group>
<Button onClick={category.onRename} variant="subtle">
{t("bank.rename")}
</Button>
</Group>
<Button
onClick={() => {
editSessionRef.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
setLatest(undefined);
}}
variant="subtle"
>
{t("bank.rename")}
</Button>
</Group>
))}
);
})}
{categories.isLoading && (
<Text role="status">{t("bank.loading")}</Text>
)}
{categories.isError && (
<BankFeedback
cached={Boolean(categories.data)}
error={categories.error}
loadingError
retry={() => void categories.refetch()}
retry={retryCategories}
/>
)}
<CategoryFields />
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
{editing && (
<Button
onClick={async () => {
const session = editSessionRef.current;
const response = await categories.refetch();
if (session !== editSessionRef.current) return;
if (response.error) setError(response.error);
else
setLatest(
response.data?.find(c => c.id === editing.id),
);
}}
variant="default"
>
<Button onClick={onReloadRecord} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
</>
))}
)}
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button
onClick={() => {
setEditing(latest);
setLatest(undefined);
setError(undefined);
}}
>
<Button onClick={onAdoptVersion}>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button loading={isPending} type="submit">
{t(editing ? "bank.save" : "bank.addCategory")}
</Button>
{editing && (
<Button
onClick={() => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
variant="default"
>
<Button onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
)}
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onDismiss} variant="default">
{t("bank.close")}
</Button>
</Group>
@@ -0,0 +1,128 @@
import type { ComponentProps } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const schema = categorySchema(categories.data, editing?.id);
const methods = useForm<CategoryValues>({
defaultValues: { name: "" },
resolver: zodResolver(schema),
});
const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false);
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
try {
if (editing)
await mutation.updateCategory.mutateAsync({
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
const result = await mutation.createCategory.mutateAsync({
name: values.name,
});
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
}
catch (error_) {
setError(error_);
}
})(event);
};
const retryCategories = () => void categories.refetch();
const onReloadRecord = async () => {
if (!editing) return;
const session = editSessionRef.current;
const response = await categories.refetch();
if (session !== editSessionRef.current) return;
if (response.error) setError(response.error);
else setLatest(response.data?.find(c => c.id === editing.id));
};
const onAdoptVersion = () => {
setEditing(latest);
setLatest(undefined);
setError(undefined);
};
const onCancel = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
};
const onDismiss = () => setOpened(false);
const categoryRows = categories.data?.map((category) => {
const onRename = () => {
editSessionRef.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
setLatest(undefined);
};
return { ...category, onRename };
});
return {
categories,
categoryRows,
editing,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onDismiss,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
retryCategories,
t,
};
}
export { useCategoryManagerVm };