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
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:
@@ -0,0 +1,74 @@
|
||||
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 } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { CategoryManager } from "./CategoryManager";
|
||||
|
||||
describe("CategoryManager", () => {
|
||||
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
});
|
||||
let category = {
|
||||
id: "default",
|
||||
name: "Другое",
|
||||
isDefault: true,
|
||||
version: 1,
|
||||
};
|
||||
let creates = 0;
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([category]),
|
||||
),
|
||||
http.post("*/api/v1/bank-cards/categories", () => {
|
||||
creates++;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.patch(
|
||||
"*/api/v1/bank-cards/categories/default",
|
||||
async ({ request }) => {
|
||||
body = await request.json();
|
||||
category = { ...category, name: "Остальное", version: 2 };
|
||||
return HttpResponse.json(category);
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<CategoryManager onClose={() => {}} />
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Другое");
|
||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: " другое " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
|
||||
await screen.findByText("Категория с таким названием уже есть.");
|
||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: "a".repeat(81) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
|
||||
await screen.findByText("Введите название от 1 до 80 символов.");
|
||||
expect(creates).toBe(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Переименовать" }));
|
||||
fireEvent.input(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: "Остальное" },
|
||||
});
|
||||
fireEvent.blur(screen.getByLabelText(/Название категории/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await screen.findByText("Остальное");
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ name: "Остальное", version: 1 }),
|
||||
);
|
||||
expect(screen.getByText("По умолчанию")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useForm,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { useBankMutations } from "../../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import classes from "../BankForms.module.css";
|
||||
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
type CategoryValues = { name: string };
|
||||
const CategoryFields: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<CategoryValues>();
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
label={t("bank.categoryName")}
|
||||
required
|
||||
{...field}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||
const methods = useForm<CategoryValues>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
||||
{ error: validationMessage("bank.validation.category") },
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!categories.data?.some(
|
||||
(c) =>
|
||||
c.id !== editing?.id &&
|
||||
c.name.toLocaleLowerCase("ru-RU") ===
|
||||
value.toLocaleLowerCase("ru-RU"),
|
||||
),
|
||||
{ error: validationMessage("bank.validation.duplicate") },
|
||||
),
|
||||
}),
|
||||
),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
const pending = !opened || methods.formState.isSubmitting;
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onExitTransitionEnd={props.onClose}
|
||||
onClose={() => !pending && setOpened(false)}
|
||||
title={t("bank.categories")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
id: editing.id,
|
||||
body: { name: values.name, version: editing.version },
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
setEditing(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={pending} className={classes.fieldset}>
|
||||
<Stack>
|
||||
{categories.data?.map((category) => (
|
||||
<Group justify="space-between" key={category.id}>
|
||||
<Group gap="xs">
|
||||
<BankOperationIcon
|
||||
kind="expense"
|
||||
categoryName={category.name}
|
||||
/>
|
||||
<Text>{category.name}</Text>
|
||||
{category.isDefault && (
|
||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
setLatest(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.rename")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
{categories.isLoading && (
|
||||
<Text role="status">{t("bank.loading")}</Text>
|
||||
)}
|
||||
{categories.isError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={categories.error}
|
||||
cached={Boolean(categories.data)}
|
||||
retry={() => void categories.refetch()}
|
||||
/>
|
||||
)}
|
||||
<CategoryFields />
|
||||
{error ? (
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
const response = await categories.refetch();
|
||||
if (response.error) setError(response.error);
|
||||
else
|
||||
setLatest(
|
||||
response.data?.find((c) => c.id === editing.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{latest && (
|
||||
<>
|
||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(latest);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.adoptVersion", { version: latest.version })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Group>
|
||||
<Button type="submit" loading={pending}>
|
||||
{t(editing ? "bank.save" : "bank.addCategory")}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setEditing(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
{t("bank.close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { CategoryManager };
|
||||
Reference in New Issue
Block a user