refactor: simplify frontend and clarify module ownership #45
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user