refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s

This commit is contained in:
2026-09-23 08:34:58 +03:00
parent 85fcfe3d07
commit b987fd8e78
190 changed files with 131 additions and 108 deletions
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
import type { BankCardValues } from "../../model/form-validation";
import { BankCardFields } from "./BankCardForm.view";
const meta = {
title: "Product/BankCards/CardFields",
component: BankCardFields,
tags: ["autodocs"],
args: { archived: false },
decorators: [
(Story) => {
const methods = useForm<BankCardValues>({
defaultValues: {
name: "Учебная повседневная карта",
bankName: "Вымышленный банк",
lastFour: "1234",
openingBalance: "-92233720368547758.08",
openingDate: "2026-08-01",
},
});
return (
<FormProvider {...methods}>
<Story />
</FormProvider>
);
},
],
parameters: {
docs: {
description: {
component:
"Поля ручной дебетовой карты на вымышленных данных. Сохранения и обращения к API нет. Архив оставляет описательные сведения доступными, а финансовые поля — только для чтения.",
},
},
},
} satisfies Meta<typeof BankCardFields>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
name: "Финансовые поля архивной карты",
args: { archived: true },
};
@@ -0,0 +1,151 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = {
id: "a",
name: "Основная",
bankName: "Банк",
lastFour: "1234",
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" },
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<BankCardForm card={value} onClose={close} />
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("BankCardForm", () => {
it("waits for the exit transition before notifying the parent", async () => {
const close = setup(card);
fireEvent.click(screen.getByRole("button", { name: "Отмена" }));
expect(close).not.toHaveBeenCalled();
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
});
it("clears optional metadata on archive while omitting protected financial fields", async () => {
const close = setup({ ...card, archivedAt: "2026-09-01" });
expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute(
"readonly",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveAttribute("readonly");
fireEvent.change(screen.getByLabelText(/Банк/), { target: { value: "" } });
fireEvent.change(screen.getByLabelText(/Последние четыре/), {
target: { value: "" },
});
let body: unknown;
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({
version: 1,
name: "Основная",
bankName: null,
lastFour: null,
paymentSystem: null,
});
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
expect(
screen.getByRole("combobox", { name: /Платёжная система/ }),
).toHaveValue("МИР");
fireEvent.click(
screen.getByRole("combobox", { name: /Платёжная система/ }),
);
fireEvent.click(await screen.findByRole("option", { name: "Visa" }));
let body: unknown;
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({ paymentSystem: "visa" });
});
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
const close = setup(card);
fireEvent.change(screen.getByLabelText(/Дата начала/), {
target: { value: "01.08.2026" },
});
server.use(
http.patch("*/api/v1/bank-cards/a", () =>
HttpResponse.json(
{ code: "BANK_OPENING_DATE_CONFLICT" },
{ status: 400 },
),
),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(
"Дата начала противоречит сохранённой истории карты.",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("01.08.2026");
expect(close).not.toHaveBeenCalled();
});
it("keeps card edit version until explicit refresh adoption", async () => {
const close = setup(card);
let version = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ ...card, version }),
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
version = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(card);
}),
);
fireEvent.change(screen.getByLabelText(/Название карты/), {
target: { value: "Мой черновик" },
});
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: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies.map((b) => b.version)).toEqual([1, 2]);
});
});
@@ -0,0 +1,151 @@
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 { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import {
type BankCardValues,
bankCardSchema,
bankMoneyValue,
bankToday,
} from "../../model/form-validation";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view";
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>({
resolver: zodResolver(bankCardSchema),
defaultValues: {
name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
paymentSystem: props.card?.paymentSystem ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
},
});
const pending =
!opened ||
methods.formState.isSubmitting ||
(fresh.isFetching && Boolean(error));
return (
<Modal
opened={opened}
onExitTransitionEnd={props.onClose}
onClose={() => !pending && setOpened(false)}
title={t(props.card ? "bank.editCard" : "bank.addCard")}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setError(undefined);
try {
const metadata = {
name: values.name,
bankName: values.bankName || null,
lastFour: values.lastFour || null,
paymentSystem: values.paymentSystem || null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
id: props.card.id,
body: {
...metadata,
...(!props.card.archivedAt ? financial : {}),
version,
},
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
})}
>
<fieldset className={classes.fieldset} disabled={pending}>
<Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
{error ? (
<>
<BankFeedback error={error} />
{props.card && (
<Button
variant="default"
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
})}
</Text>
<Button
variant="default"
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button type="submit" loading={pending}>
{t("bank.save")}
</Button>
<Button variant="default" onClick={() => setOpened(false)}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
);
};
export { BankCardForm };
@@ -0,0 +1,112 @@
import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "../../model/form-validation";
type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const {
control,
register,
formState: { errors },
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
return (
<Stack>
<TextInput
label={t("bank.name")}
required
{...register("name")}
error={errors.name?.message}
/>
<TextInput
label={t("bank.bankName")}
{...register("bankName")}
error={errors.bankName?.message}
/>
<TextInput
label={t("bank.lastFour")}
inputMode="numeric"
{...register("lastFour")}
error={errors.lastFour?.message}
/>
<Controller
control={control}
name="paymentSystem"
render={({ field }) => (
<Select
{...field}
value={field.value || null}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.paymentSystem")}
placeholder={t("bank.paymentSystemUnknown")}
clearable
data={["mir", "visa", "mastercard"].map((value) => ({
value,
label: t(`bank.paymentSystems.${value}`),
}))}
comboboxProps={{ withinPortal: false }}
error={errors.paymentSystem?.message}
/>
)}
/>
<TextInput
label={t("bank.openingBalance")}
required
inputMode="decimal"
readOnly={props.archived}
{...register("openingBalance")}
error={errors.openingBalance?.message}
/>
<Controller
control={control}
name="openingDate"
render={({ field }) => (
<DateInput
{...field}
label={t("bank.openingDate")}
required
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
dateParser={(value) =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null
}
fixOnBlur={false}
onInput={(event) =>
field.onChange(bankDateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={bankToday()}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
}}
readOnly={props.archived}
error={errors.openingDate?.message}
/>
)}
/>
<Text size="sm" c="dimmed">
{t("bank.openingHint")}
</Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
);
};
export { BankCardFields };