refactor: unify bank form types and actions
This commit is contained in:
@@ -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 => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user