From ff67a768addc0c4ba55247f5e2d9d5d5d5aec734 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:00 +0300 Subject: [PATCH] refactor: unify bank form types and actions --- .../bank-card/model/category.schema.ts | 4 +- .../ui/CategoryManager/CategoryManager.tsx | 14 ++--- .../ui/CategoryManager/CategoryManager.vm.ts | 23 ++++---- .../OperationForm.schema.test.ts | 55 +++++++++++++++++++ .../ui/OperationForm/OperationForm.schema.ts | 27 +++++---- .../ui/OperationForm/OperationForm.types.ts | 17 ++---- .../card/ui/OperationForm/OperationForm.vm.ts | 5 +- 7 files changed, 98 insertions(+), 47 deletions(-) create mode 100644 src/pages/card/ui/OperationForm/OperationForm.schema.test.ts diff --git a/src/entities/bank-card/model/category.schema.ts b/src/entities/bank-card/model/category.schema.ts index eaae5a2..f8c4369 100644 --- a/src/entities/bank-card/model/category.schema.ts +++ b/src/entities/bank-card/model/category.schema.ts @@ -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>; + 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 }; diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index fb54ff2..3c0d205 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -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(); diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts index 4a6ebe1..f0328d7 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts @@ -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 }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts b/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts new file mode 100644 index 0000000..7fead5f --- /dev/null +++ b/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts @@ -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"]]); + }); +}); diff --git a/src/pages/card/ui/OperationForm/OperationForm.schema.ts b/src/pages/card/ui/OperationForm/OperationForm.schema.ts index 7e233fc..b73b470 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.schema.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.schema.ts @@ -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; + 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 }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts index 63f77fb..d060017 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.types.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts @@ -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"; diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index 449f0d1..e6d5ae1 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => { const [submittedValues, setSubmittedValues] = useState(); const [error, setError] = useState(); 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 => ({