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
@@ -0,0 +1,76 @@
import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useBankCardFieldsVm() {
const {
control,
formState: { errors },
register,
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange: (value: null | string) => void = value =>
paymentSystemField.onChange(value ?? "");
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange: (value: null | string) => void = value =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register,
t,
};
}
export { useBankCardFieldsVm };
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
import type { BankCardValues } from "../../model/form-validation";
import type { BankCardValues } from "../../model/form.schema";
import { BankCardFields } from "./BankCardForm.view";
@@ -20,6 +20,7 @@ const meta = {
openingDate: "2026-08-01",
},
});
return (
<FormProvider {...methods}>
<Story />
@@ -38,8 +39,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/BankCards/CardFields",
} satisfies Meta<typeof BankCardFields>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
args: { archived: true },
@@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card: BankCard
= {
archivedAt: null,
balance: { currency: "RUB", value: "50" },
bankName: "Банк",
@@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
@@ -41,8 +44,10 @@ function setup(value: BankCard) {
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("BankCardForm", () => {
it("waits for the exit transition before notifying the parent", async () => {
const close = setup(card);
@@ -64,20 +69,23 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json(card);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(body).toEqual(
{
bankName: null,
lastFour: null,
name: "Основная",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */));
},
/* eslint-enable unicorn/no-null */
);
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
@@ -92,6 +100,7 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
@@ -132,6 +141,7 @@ describe("BankCardForm", () => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
version = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -146,9 +156,11 @@ describe("BankCardForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
@@ -1,121 +1,61 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import {
bankCardSchema,
type BankCardValues,
bankMoneyValue,
bankToday,
} from "../../model/form-validation";
import { type BankCard } from "../../api/queries";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
const BankCardForm: FC<BankCardFormProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [intent] = useState(() => crypto.randomUUID());
const [version, setVersion] = useState(props.card?.version ?? 1);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations();
const methods = useForm<BankCardValues>({
defaultValues: {
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
name: props.card?.name ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
},
resolver: zodResolver(bankCardSchema),
});
const isPending
= !opened
|| methods.formState.isSubmitting
|| (fresh.isFetching && Boolean(error));
const {
dialogTitle,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
t,
} = useBankCardFormVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t(props.card ? "bank.editCard" : "bank.addCard")}
title={dialogTitle}
>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setError(undefined);
try {
const metadata = {
bankName: values.bankName || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
lastFour: values.lastFour || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
name: values.name,
paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
body: {
...metadata,
...(!props.card.archivedAt && financial),
version,
},
id: props.card.id,
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
})}
>
<form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
{props.card && (
<Button
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
variant="default"
>
<Button onClick={onReloadRecord} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
</>
))}
)}
{latest && (
<>
<Text>
@@ -123,23 +63,19 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
})}
</Text>
<Button
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
variant="default"
>
<Button onClick={onAdoptVersion} variant="default">
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button loading={isPending} type="submit">
{t("bank.save")}
</Button>
<Button onClick={() => setOpened(false)} variant="default">
<Button onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -2,24 +2,28 @@ import type { FC } from "react";
import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "../../model/form-validation";
import { bankToday } from "../../model/form-validation";
import { useBankCardFieldsVm } from "./BankCardFields.vm";
type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const {
control,
formState: { errors },
dateParser,
errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange,
openingDateField,
openingDateValue,
paymentSystemField,
paymentSystems,
register,
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
t,
} = useBankCardFieldsVm();
return (
<Stack>
<TextInput
@@ -28,37 +32,36 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("name")}
error={errors.name?.message}
/>
<TextInput
label={t("bank.bankName")}
{...register("bankName")}
error={errors.bankName?.message}
/>
<TextInput
inputMode="numeric"
label={t("bank.lastFour")}
{...register("lastFour")}
error={errors.lastFour?.message}
/>
<Controller
control={control}
name="paymentSystem"
render={({ field }) => (
<Select
{...field}
clearable
comboboxProps={{ withinPortal: false }}
data={["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}))}
error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={value => field.onChange(value ?? "")}
placeholder={t("bank.paymentSystemUnknown")}
value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
/>
)}
<Select
{...paymentSystemField}
clearable
comboboxProps={{ withinPortal: false }}
data={paymentSystems}
error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={onPaymentSystemChange}
placeholder={t("bank.paymentSystemUnknown")}
value={
paymentSystemField.value
// eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
|| null
}
/>
<TextInput
inputMode="decimal"
label={t("bank.openingBalance")}
@@ -67,43 +70,31 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("openingBalance")}
error={errors.openingBalance?.message}
/>
<Controller
control={control}
name="openingDate"
render={({ field }) => (
<DateInput
{...field}
dateParser={value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.openingDate?.message}
fixOnBlur={false}
label={t("bank.openingDate")}
maxDate={bankToday()}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
}}
onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(bankDateInputValue(event.currentTarget.value))}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
readOnly={props.archived}
required
value={
(bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined
}
valueFormat="DD.MM.YYYY"
/>
)}
<DateInput
{...openingDateField}
dateParser={dateParser}
error={errors.openingDate?.message}
fixOnBlur={false}
label={t("bank.openingDate")}
maxDate={bankToday()}
onBlur={onOpeningDateBlur}
onChange={onOpeningDateChange}
onInput={onOpeningDateInput}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
readOnly={props.archived}
required
value={openingDateValue}
valueFormat="DD.MM.YYYY"
/>
<Text c="dimmed" size="sm">
{t("bank.openingHint")}
</Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
);
@@ -0,0 +1,127 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
function useBankCardFormVm(props: BankCardFormProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [intent] = useState(() => crypto.randomUUID());
const [version, setVersion] = useState(props.card?.version ?? 1);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations();
const methods = useForm<BankCardValues>({
defaultValues: {
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
name: props.card?.name ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
},
resolver: zodResolver(bankCardSchema),
});
const isPending
= !opened
|| methods.formState.isSubmitting
|| (fresh.isFetching && Boolean(error));
const onRequestClose = () => !isPending && setOpened(false);
const dialogTitle = t(props.card ? "bank.editCard" : "bank.addCard");
const onSubmit = methods.handleSubmit(async (values) => {
setError(undefined);
try {
const metadata = {
bankName:
values.bankName
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
lastFour:
values.lastFour
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
name: values.name,
paymentSystem:
values.paymentSystem
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|| null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
body: {
...metadata,
...(!props.card.archivedAt && financial),
version,
},
id: props.card.id,
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
});
const onReloadRecord = async () => {
const response = await fresh.refetch();
if (response.data && !response.error) setLatest(response.data);
else setError(response.error);
};
const onAdoptVersion = () => {
if (!latest) return;
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
};
const onCancel = () => setOpened(false);
return {
dialogTitle,
error,
isPending,
latest,
methods,
onAdoptVersion,
onCancel,
onReloadRecord,
onRequestClose,
onSubmit,
opened,
t,
version,
};
}
export { useBankCardFormVm };