refactor: simplify frontend and clarify module ownership #45

Merged
ksv741 merged 16 commits from codex/frontend-calendar-form-cleanup into develop 2026-10-01 21:07:51 +03:00
7 changed files with 98 additions and 47 deletions
Showing only changes of commit ff67a768ad - Show all commits
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({
name: z
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
});
}
export { categorySchema };
export { categorySchema, type CategoryValues };
@@ -12,16 +12,14 @@ import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import { useCategoryManagerVm } from "./CategoryManager.vm";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
import {
type CategoryManagerProps,
useCategoryManagerVm,
} from "./CategoryManager.vm";
const CategoryFields: FC = () => {
const { t } = useTranslation();
@@ -12,7 +12,7 @@ import {
useUpdateExpenseCategory,
} from "../../api/mutations";
import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema";
import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = {
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
finishEditing();
}
catch (error_) {
setError(error_);
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
};
const onCancel = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
finishEditing();
setError(undefined);
};
@@ -131,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
};
}
export { useCategoryManagerVm };
export { type CategoryManagerProps, useCategoryManagerVm };
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { BankCard } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema";
const card: BankCard = {
archivedAt: null,
balance: { currency: "RUB", value: "0" },
bankName: null,
id: "card",
lastFour: null,
name: "Карта",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
};
const values: OperationValues = {
amount: "1",
cardId: card.id,
categoryId: "",
date: "2026-01-02",
description: "",
kind: "expense",
originalExpenseId: "",
targetCardId: "",
};
describe("expense category validation", () => {
it.each([
["", [{ id: "default", isDefault: true }], true],
["chosen", [{ id: "chosen", isDefault: false }], true],
["", [{ id: "chosen", isDefault: false }], false],
["unknown", [{ id: "default", isDefault: true }], false],
["", undefined, false],
] as const)("checks category %s with current categories %j", (
categoryId, categories, valid,
) => {
const schema = createOperationSchema({
cards: [card],
categories: categories ? [...categories] : undefined,
});
const result = schema.safeParse({ ...values, categoryId });
expect(result.success).toBe(valid);
if (!result.success)
// eslint-disable-next-line vitest/no-conditional-expect -- Invalid table cases retain the category field error boundary.
expect(result.error.issues.map(issue => issue.path))
.toEqual([["categoryId"]]);
});
});
@@ -10,8 +10,6 @@ import {
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode {
Custom = "custom",
@@ -43,6 +41,8 @@ const operationSchema = z.object({
targetCardId: z.string(),
});
type OperationValues = z.infer<typeof operationSchema>;
function createOperationSchema({
cards,
categories,
@@ -83,16 +83,19 @@ function createOperationSchema({
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund")
if (values.kind === "refund") {
validateRefund(values, context, expense, limit);
if (
values.kind === "expense"
&& !categories?.some(
c =>
c.id
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
)
)
return;
}
if (values.kind !== "expense") return;
const effectiveCategoryId
= values.categoryId || categories?.find(c => c.isDefault)?.id;
if (!categories?.some(c => c.id === effectiveCategoryId))
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
@@ -128,4 +131,4 @@ function validateRefund(
});
}
export { createOperationSchema };
export { createOperationSchema, type OperationValues };
@@ -4,6 +4,8 @@ import type {
BankOperation,
} from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.schema";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
@@ -26,20 +28,11 @@ type OperationFormProps = {
operation?: BankOperation;
};
type OperationValues = {
amount: string;
cardId: string;
categoryId: string;
date: string;
description: string;
kind: BankOperation["kind"];
originalExpenseId: string;
targetCardId: string;
};
export type {
Choice,
OperationFieldsProps,
OperationFormProps,
OperationValues,
};
export { type OperationValues } from "./OperationForm.schema";
@@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation();
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
}),
...(values.kind === "expense" && {
categoryId:
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
values.categoryId || defaultCategoryId,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
cards: options,
categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
defaultCategory: defaultCategoryId ?? "",
editing: Boolean(props.operation),
kind,
kinds: bankOperationKinds.map(value => ({