refactor: unify bank form types and actions

This commit is contained in:
2026-10-01 20:53:00 +03:00
parent 10baefda79
commit ff67a768ad
7 changed files with 98 additions and 47 deletions
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/types"; import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80; const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) { function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({ return z.object({
name: z 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 { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import { useCategoryManagerVm } from "./CategoryManager.vm"; import {
type CategoryManagerProps,
type CategoryManagerProps = { useCategoryManagerVm,
onClose: () => void; } from "./CategoryManager.vm";
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => { const CategoryFields: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,7 +12,7 @@ import {
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "../../api/mutations"; } from "../../api/mutations";
import { useBankCategories } from "../../api/queries"; import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = { type CategoryManagerProps = {
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
}; };
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) { function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus(); useBankFormFocus();
const [opened, setOpened] = useState(true); const [opened, setOpened] = useState(true);
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting; const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false); const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => { void methods.handleSubmit(async (values) => {
setError(undefined); setError(undefined);
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} }
catch (error_) { catch (error_) {
setError(error_); setError(error_);
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
const onCancel = () => { const onCancel = () => {
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined); 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"; } from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod. // ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode { enum FormIssueCode {
Custom = "custom", Custom = "custom",
@@ -43,6 +41,8 @@ const operationSchema = z.object({
targetCardId: z.string(), targetCardId: z.string(),
}); });
type OperationValues = z.infer<typeof operationSchema>;
function createOperationSchema({ function createOperationSchema({
cards, cards,
categories, categories,
@@ -83,16 +83,19 @@ function createOperationSchema({
path: ["date"], path: ["date"],
...validationMessage("bank.validation.start")(undefined), ...validationMessage("bank.validation.start")(undefined),
}); });
if (values.kind === "refund")
if (values.kind === "refund") {
validateRefund(values, context, expense, limit); validateRefund(values, context, expense, limit);
if (
values.kind === "expense" return;
&& !categories?.some( }
c =>
c.id if (values.kind !== "expense") return;
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
) const effectiveCategoryId
) = values.categoryId || categories?.find(c => c.isDefault)?.id;
if (!categories?.some(c => c.id === effectiveCategoryId))
context.addIssue({ context.addIssue({
code: FormIssueCode.Custom, code: FormIssueCode.Custom,
path: ["categoryId"], path: ["categoryId"],
@@ -128,4 +131,4 @@ function validateRefund(
}); });
} }
export { createOperationSchema }; export { createOperationSchema, type OperationValues };
@@ -4,6 +4,8 @@ import type {
BankOperation, BankOperation,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.schema";
type Choice = { label: string; value: string }; type Choice = { label: string; value: string };
type OperationFieldsProps = { type OperationFieldsProps = {
@@ -26,20 +28,11 @@ type OperationFormProps = {
operation?: BankOperation; operation?: BankOperation;
}; };
type OperationValues = {
amount: string;
cardId: string;
categoryId: string;
date: string;
description: string;
kind: BankOperation["kind"];
originalExpenseId: string;
targetCardId: string;
};
export type { export type {
Choice, Choice,
OperationFieldsProps, OperationFieldsProps,
OperationFormProps, OperationFormProps,
OperationValues,
}; };
export { type OperationValues } from "./OperationForm.schema";
@@ -43,6 +43,7 @@ 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 defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
const operationCreation = useCreateBankOperation(); const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation(); const updateOperation = useUpdateBankOperation();
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
}), }),
...(values.kind === "expense" && { ...(values.kind === "expense" && {
categoryId: categoryId:
values.categoryId || categories.data?.find(c => c.isDefault)?.id, values.categoryId || defaultCategoryId,
}), }),
...(values.kind === "refund" && { ...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId, originalExpenseId: values.originalExpenseId,
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
cards: options, cards: options,
categories: categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [], categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "", defaultCategory: defaultCategoryId ?? "",
editing: Boolean(props.operation), editing: Boolean(props.operation),
kind, kind,
kinds: bankOperationKinds.map(value => ({ kinds: bankOperationKinds.map(value => ({