refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -23,18 +23,23 @@ describe("CategoryManager", () => {
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: "a", isDefault: false, name: "Первая", version: 1 },
|
||||
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
|
||||
];
|
||||
|
||||
let reads = 0;
|
||||
|
||||
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
|
||||
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 =>
|
||||
category.id === "a" && reads > 1
|
||||
@@ -51,6 +56,7 @@ describe("CategoryManager", () => {
|
||||
"*/api/v1/bank-cards/categories/:id",
|
||||
async ({ params, request }) => {
|
||||
patches.push({ body: await request.json(), id: params.id });
|
||||
|
||||
return patches.length === 1
|
||||
? HttpResponse.json(
|
||||
{ code: "BANK_VERSION_CONFLICT" },
|
||||
@@ -67,7 +73,11 @@ describe("CategoryManager", () => {
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
|
||||
|
||||
const renameButtons = await screen.findAllByRole("button", {
|
||||
name: "Переименовать",
|
||||
});
|
||||
|
||||
fireEvent.click(renameButtons[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
fireEvent.click(
|
||||
@@ -122,6 +132,7 @@ describe("CategoryManager", () => {
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/categories/a", () => {
|
||||
version = 2;
|
||||
|
||||
return ++writes === 1
|
||||
? HttpResponse.json(
|
||||
{ code: "BANK_VERSION_CONFLICT" },
|
||||
@@ -163,12 +174,14 @@ describe("CategoryManager", () => {
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
let category = {
|
||||
id: "default",
|
||||
isDefault: true,
|
||||
name: "Другое",
|
||||
version: 1,
|
||||
};
|
||||
|
||||
let creates = 0;
|
||||
let body: unknown;
|
||||
server.use(
|
||||
@@ -177,6 +190,7 @@ describe("CategoryManager", () => {
|
||||
),
|
||||
http.post("*/api/v1/bank-cards/categories", () => {
|
||||
creates++;
|
||||
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.patch(
|
||||
@@ -184,6 +198,7 @@ describe("CategoryManager", () => {
|
||||
async ({ request }) => {
|
||||
body = await request.json();
|
||||
category = { ...category, name: "Остальное", version: 2 };
|
||||
|
||||
return HttpResponse.json(category);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -8,218 +7,148 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useForm,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { OperationIcon } from "@wealthledger/icons";
|
||||
import { type FC } from "react";
|
||||
import { FormProvider, useController, 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 { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
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;
|
||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
|
||||
const CategoryFields: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<CategoryValues>();
|
||||
|
||||
const { field: nameField } = useController({ control, name: "name" });
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
label={t("bank.categoryName")}
|
||||
required
|
||||
{...field}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={t("bank.categoryName")}
|
||||
required
|
||||
{...nameField}
|
||||
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 editSessionRef = useRef(0);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||
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"),
|
||||
),
|
||||
{ error: validationMessage("bank.validation.duplicate") },
|
||||
),
|
||||
});
|
||||
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
const {
|
||||
categories,
|
||||
categoryRows,
|
||||
editing,
|
||||
error,
|
||||
isPending,
|
||||
latest,
|
||||
methods,
|
||||
onAdoptVersion,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
onReloadRecord,
|
||||
onRequestClose,
|
||||
onSubmit,
|
||||
opened,
|
||||
retryCategories,
|
||||
t,
|
||||
} = useCategoryManagerVm(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("bank.categories")}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
void methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
body: { name: values.name, version: editing.version },
|
||||
id: editing.id,
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
}
|
||||
})(event);
|
||||
}}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
||||
<Stack>
|
||||
{categories.data?.map(category => (
|
||||
<Group justify="space-between" key={category.id}>
|
||||
<Group gap="xs">
|
||||
<BankOperationIcon
|
||||
categoryName={category.name}
|
||||
kind="expense"
|
||||
/>
|
||||
<Text>{category.name}</Text>
|
||||
{category.isDefault && (
|
||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||
)}
|
||||
{categoryRows?.map((category) => {
|
||||
return (
|
||||
<Group justify="space-between" key={category.id}>
|
||||
<Group gap="xs">
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance("expense", category.name)}
|
||||
/>
|
||||
|
||||
<Text>{category.name}</Text>
|
||||
|
||||
{category.isDefault && (
|
||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button onClick={category.onRename} variant="subtle">
|
||||
{t("bank.rename")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
onClick={() => {
|
||||
editSessionRef.current++;
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
setLatest(undefined);
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{t("bank.rename")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.isLoading && (
|
||||
<Text role="status">{t("bank.loading")}</Text>
|
||||
)}
|
||||
|
||||
{categories.isError && (
|
||||
<BankFeedback
|
||||
cached={Boolean(categories.data)}
|
||||
error={categories.error}
|
||||
loadingError
|
||||
retry={() => void categories.refetch()}
|
||||
retry={retryCategories}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CategoryFields />
|
||||
{!!(error) && ((
|
||||
|
||||
{!!error && (
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
|
||||
{editing && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const session = editSessionRef.current;
|
||||
const response = await categories.refetch();
|
||||
if (session !== editSessionRef.current) return;
|
||||
if (response.error) setError(response.error);
|
||||
else
|
||||
setLatest(
|
||||
response.data?.find(c => c.id === editing.id),
|
||||
);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<Button onClick={onReloadRecord} variant="default">
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
|
||||
{latest && latest.id === editing?.id && (
|
||||
<>
|
||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(latest);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
|
||||
<Button onClick={onAdoptVersion}>
|
||||
{t("bank.adoptVersion", { version: latest.version })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<Button loading={isPending} type="submit">
|
||||
{t(editing ? "bank.save" : "bank.addCategory")}
|
||||
</Button>
|
||||
|
||||
{editing && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<Button onClick={onCancel} variant="default">
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setOpened(false)} variant="default">
|
||||
|
||||
<Button onClick={onDismiss} variant="default">
|
||||
{t("bank.close")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useBankMutations } from "../../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||
import { categorySchema } from "../../model/category.schema";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
|
||||
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const editSessionRef = useRef(0);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||
const schema = categorySchema(categories.data, editing?.id);
|
||||
|
||||
const methods = useForm<CategoryValues>({
|
||||
defaultValues: { name: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
const onRequestClose = () => !isPending && setOpened(false);
|
||||
|
||||
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||
void methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
body: { name: values.name, version: editing.version },
|
||||
id: editing.id,
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
}
|
||||
})(event);
|
||||
};
|
||||
|
||||
const retryCategories = () => void categories.refetch();
|
||||
|
||||
const onReloadRecord = async () => {
|
||||
if (!editing) return;
|
||||
const session = editSessionRef.current;
|
||||
const response = await categories.refetch();
|
||||
if (session !== editSessionRef.current) return;
|
||||
if (response.error) setError(response.error);
|
||||
else setLatest(response.data?.find(c => c.id === editing.id));
|
||||
};
|
||||
|
||||
const onAdoptVersion = () => {
|
||||
setEditing(latest);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const onDismiss = () => setOpened(false);
|
||||
|
||||
const categoryRows = categories.data?.map((category) => {
|
||||
const onRename = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
setLatest(undefined);
|
||||
};
|
||||
|
||||
return { ...category, onRename };
|
||||
});
|
||||
|
||||
return {
|
||||
categories,
|
||||
categoryRows,
|
||||
editing,
|
||||
error,
|
||||
isPending,
|
||||
latest,
|
||||
methods,
|
||||
onAdoptVersion,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
onReloadRecord,
|
||||
onRequestClose,
|
||||
onSubmit,
|
||||
opened,
|
||||
retryCategories,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCategoryManagerVm };
|
||||
Reference in New Issue
Block a user