refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user