chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
@@ -7,36 +7,36 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { http, HttpResponse } 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("ignores a late reload for A while editing B and submits B's draft and version", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
const categories = [
|
||||
{ id: "a", name: "Первая", isDefault: false, version: 1 },
|
||||
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
|
||||
{ id: "a", isDefault: false, name: "Первая", version: 1 },
|
||||
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
|
||||
];
|
||||
let reads = 0;
|
||||
let release = () => {};
|
||||
const reloaded = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const patches: { id: unknown; body: unknown }[] = [];
|
||||
|
||||
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
|
||||
const patches: { body: unknown; id: unknown }[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", async () => {
|
||||
reads++;
|
||||
if (reads === 3) await reloaded;
|
||||
return HttpResponse.json(
|
||||
categories.map((category) =>
|
||||
categories.map(category =>
|
||||
category.id === "a" && reads > 1
|
||||
? {
|
||||
...category,
|
||||
@@ -49,8 +49,8 @@ describe("CategoryManager", () => {
|
||||
}),
|
||||
http.patch(
|
||||
"*/api/v1/bank-cards/categories/:id",
|
||||
async ({ request, params }) => {
|
||||
patches.push({ id: params.id, body: await request.json() });
|
||||
async ({ params, request }) => {
|
||||
patches.push({ body: await request.json(), id: params.id });
|
||||
return patches.length === 1
|
||||
? HttpResponse.json(
|
||||
{ code: "BANK_VERSION_CONFLICT" },
|
||||
@@ -67,9 +67,8 @@ describe("CategoryManager", () => {
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
|
||||
);
|
||||
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
|
||||
fireEvent.click(renameButtons[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||
@@ -99,8 +98,8 @@ describe("CategoryManager", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() =>
|
||||
expect(patches).toEqual([
|
||||
{ id: "a", body: { name: "Первая", version: 1 } },
|
||||
{ id: "b", body: { name: "Новая вторая", version: 4 } },
|
||||
{ body: { name: "Первая", version: 1 }, id: "a" },
|
||||
{ body: { name: "Новая вторая", version: 4 }, id: "b" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -110,15 +109,15 @@ describe("CategoryManager", () => {
|
||||
async (action) => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
let version = 1;
|
||||
let writes = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "a", name: "Первая", isDefault: false, version },
|
||||
{ id: "a", isDefault: false, name: "Первая", version },
|
||||
]),
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/categories/a", () => {
|
||||
@@ -161,13 +160,13 @@ describe("CategoryManager", () => {
|
||||
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
let category = {
|
||||
id: "default",
|
||||
name: "Другое",
|
||||
isDefault: true,
|
||||
name: "Другое",
|
||||
version: 1,
|
||||
};
|
||||
let creates = 0;
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} 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";
|
||||
@@ -60,44 +62,40 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const editSession = useRef(0);
|
||||
const editSessionRef = useRef(0);
|
||||
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") },
|
||||
const schema = 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"),
|
||||
),
|
||||
}),
|
||||
),
|
||||
defaultValues: { name: "" },
|
||||
{ error: validationMessage("bank.validation.duplicate") },
|
||||
),
|
||||
});
|
||||
const pending = !opened || methods.formState.isSubmitting;
|
||||
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
||||
closeOnClickOutside={!isPending}
|
||||
closeOnEscape={!isPending}
|
||||
onClose={() => !isPending && setOpened(false)}
|
||||
onExitTransitionEnd={props.onClose}
|
||||
onClose={() => !pending && setOpened(false)}
|
||||
opened={opened}
|
||||
title={t("bank.categories")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
@@ -107,8 +105,8 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
id: editing.id,
|
||||
body: { name: values.name, version: editing.version },
|
||||
id: editing.id,
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
@@ -116,24 +114,25 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
editSession.current++;
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
}
|
||||
})(event);
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className={classes.fieldset}>
|
||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||
<Stack>
|
||||
{categories.data?.map((category) => (
|
||||
{categories.data?.map(category => (
|
||||
<Group justify="space-between" key={category.id}>
|
||||
<Group gap="xs">
|
||||
<BankOperationIcon
|
||||
kind="expense"
|
||||
categoryName={category.name}
|
||||
kind="expense"
|
||||
/>
|
||||
<Text>{category.name}</Text>
|
||||
{category.isDefault && (
|
||||
@@ -141,14 +140,14 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
editSession.current++;
|
||||
editSessionRef.current++;
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
setLatest(undefined);
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{t("bank.rename")}
|
||||
</Button>
|
||||
@@ -159,35 +158,35 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
)}
|
||||
{categories.isError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={categories.error}
|
||||
cached={Boolean(categories.data)}
|
||||
error={categories.error}
|
||||
loadingError
|
||||
retry={() => void categories.refetch()}
|
||||
/>
|
||||
)}
|
||||
<CategoryFields />
|
||||
{error ? (
|
||||
{!!(error) && ((
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
const session = editSession.current;
|
||||
const session = editSessionRef.current;
|
||||
const response = await categories.refetch();
|
||||
if (session !== editSession.current) return;
|
||||
if (session !== editSessionRef.current) return;
|
||||
if (response.error) setError(response.error);
|
||||
else
|
||||
setLatest(
|
||||
response.data?.find((c) => c.id === editing.id),
|
||||
response.data?.find(c => c.id === editing.id),
|
||||
);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
))}
|
||||
{latest && latest.id === editing?.id && (
|
||||
<>
|
||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||
@@ -203,24 +202,24 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
<Group>
|
||||
<Button type="submit" loading={pending}>
|
||||
<Button loading={isPending} type="submit">
|
||||
{t(editing ? "bank.save" : "bank.addCategory")}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
editSession.current++;
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
<Button onClick={() => setOpened(false)} variant="default">
|
||||
{t("bank.close")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user