refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -2,7 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
bankDateInputValue,
@@ -11,15 +10,12 @@ import {
type BankOperation,
bankOperationKinds,
bankToday,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
@@ -27,53 +23,7 @@ import type {
OperationValues,
} from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode { Custom = "custom" }
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function validateRefund(values: OperationValues, context: z.RefinementCtx,
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: FormIssueCode.Custom,
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (
limit
&& amount !== null
&& amount > (moneyCents(limit.value) ?? 0n)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
import { createOperationSchema } from "./OperationForm.schema";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
@@ -83,19 +33,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
defaultValues: {
amount: props.operation?.amount.value ?? "",
@@ -108,84 +63,50 @@ const useOperationFormVm = (props: OperationFormProps) => {
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: zodResolver(
operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find(c => c.id === values.cardId);
const target = props.cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
context.addIssue({
code: FormIssueCode.Custom,
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate)
|| (target
&& values.kind === "transfer"
&& values.date < target.openingDate)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") validateRefund(values, context, sourceQuery.data,
props.operation ? refundLimit : sourceQuery.data?.availableRefund);
if (
values.kind === "expense"
&& !categories.data?.some(
c =>
c.id
=== (values.categoryId
|| categories.data?.find(c => c.isDefault)?.id),
)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
createOperationSchema({
cards: props.cards,
categories: categories.data,
expense: sourceQuery.data,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
submittedValues,
}),
),
});
const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter(c => !c.archivedAt)
.map(c => ({ label: c.name, value: c.id }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
cardId: values.cardId,
kind: values.kind,
...((values.kind === "transfer") && { targetCardId: values.targetCardId }),
...((values.kind === "expense") && {
...(values.kind === "transfer" && {
targetCardId: values.targetCardId,
}),
...(values.kind === "expense" && {
categoryId:
values.categoryId
|| categories.data?.find(c => c.isDefault)?.id,
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
}),
...((values.kind === "refund") && { originalExpenseId: values.originalExpenseId }),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
@@ -193,6 +114,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
body: { ...body, version },
@@ -209,6 +131,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
setError(error_);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
@@ -231,16 +154,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
}),
value: item.id,
});
const expenseOptions = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const expenseOptions
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
categoriesError: categories.isError ? categories.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
categoriesError: categories.isError
? categories.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: expenses.isError ? expenses.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
expensesError: expenses.isError
? expenses.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
@@ -249,7 +180,9 @@ const useOperationFormVm = (props: OperationFormProps) => {
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError ? sourceQuery.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
sourceError: sourceQuery.isError
? sourceQuery.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
view: {
categoriesOpen,
editing: Boolean(props.operation),