refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s

This commit was merged in pull request #44.
This commit is contained in:
2026-09-28 20:01:37 +03:00
parent a4046dbe87
commit a7537da344
25 changed files with 414 additions and 547 deletions
@@ -126,6 +126,32 @@ function setup(
}
describe("CardPage", () => {
it.each([
["Изменить карту", "Изменить карту"],
["Добавить операцию", "Добавить операцию"],
["Изменить", "Изменить операцию"],
["Удалить", "Удалить операцию?"],
])("opens %s and restores focus after cancellation", async (action, title) => {
setup();
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards/operations/:id", ({ params }) =>
HttpResponse.json({ ...operation, id: params.id }),
),
);
await screen.findByText("Перевод на «Запасная»");
const trigger = screen.getByRole("button", { name: action });
await waitFor(() => expect(trigger).toBeEnabled());
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: title });
fireEvent.click(within(dialog).getByRole("button", { name: "Отмена" }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
+5 -5
View File
@@ -14,14 +14,14 @@ const CardPage: FC = () => {
<>
<CardPageView vm={vm} />
{vm.editingCard && vm.card.data && (
<BankCardForm card={vm.card.data} onClose={vm.closeEditing} />
{vm.editingCard && vm.card && (
<BankCardForm card={vm.card} onClose={vm.closeEditing} />
)}
{vm.operation && vm.card.data && vm.cards.data && (
{vm.operation && vm.card && vm.cards && (
<OperationForm
card={vm.card.data}
cards={vm.cards.data}
card={vm.card}
cards={vm.cards}
onClose={vm.closeOperation}
operation={vm.editableOperation}
/>
+29 -50
View File
@@ -41,7 +41,7 @@ type CardPageViewProps = { vm: CardPageVm };
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
const card = vm.card;
if (!card) return;
@@ -70,11 +70,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
disabled={!row.editable}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
@@ -86,11 +82,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
disabled={!row.editable}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
@@ -125,7 +117,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.isLoading) {
if (vm.history.loading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
@@ -142,24 +134,18 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
} = useCardPagePresentationVm(vm);
const { t } = useTranslation();
const card = vm.card;
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
if (!card) {
if (vm.card.isLoading)
if (vm.cardLoading)
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack>
@@ -184,7 +170,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")}
</Button>
{!vm.notFound && <Button onClick={retryCard}>{t("bank.retry")}</Button>}
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group>
)}
error
@@ -207,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<Group className={classes.cardActions} gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.editCard")}
onClick={onEditCard}
onClick={vm.openCardEditor}
radius="md"
size="md"
title={t("bank.editCard")}
@@ -220,7 +206,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
aria-label={archiveActionLabel}
color="red"
loading={vm.archivePending}
onClick={onToggleArchive}
onClick={vm.toggleArchive}
radius="md"
size="md"
title={archiveActionLabel}
@@ -234,40 +220,33 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div>
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={retryCard} />
<BankFeedback error={vm.archiveError} retry={vm.retryCard} />
)}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
{vm.card.isError && (
{vm.cardRefreshError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={retryCard}>{t("bank.retry")}</Button>
<Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
{vm.cardsFeedback && (
<BankFeedback
cached={Boolean(vm.cards.data)}
error={vm.cards.error}
{...vm.cardsFeedback}
loadingError
retry={retryCards}
retry={vm.retryCards}
/>
)}
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView
data={vm.summary.data}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={retrySummary}
/>
<BankSummaryView {...vm.summary} />
<Group align="end" justify="space-between">
<Group gap="xs">
@@ -275,8 +254,8 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<ActionIcon
aria-label={t("bank.addOperation")}
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={onAddOperation}
disabled={!vm.canAddOperation}
onClick={vm.openNewOperation}
size="lg"
title={t("bank.addOperation")}
variant="subtle"
@@ -303,24 +282,24 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
<Stack
aria-busy={vm.history.isFetching}
aria-busy={vm.history.refreshing}
className={classes.operations}
data-pending={vm.history.isPlaceholderData || undefined}
data-pending={vm.history.changingPage || undefined}
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
>
<CardPageViewHistory {...props} />
</Stack>
{vm.history.isError && (
{vm.history.error && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={retryHistory}>{t("bank.retry")}</Button>
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.data && vm.pageSize !== "all" && (
{vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group
aria-label={t("bank.pagination")}
component="nav"
@@ -329,7 +308,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef}
>
<Pagination
disabled={vm.history.isFetching}
disabled={vm.history.refreshing}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
+33 -10
View File
@@ -7,9 +7,9 @@ import {
bankPeriod,
useBankCard,
useBankCards,
useBankMutations,
useBankOperations,
useBankSummary,
useUpdateBankCard,
} from "@/entities/bank-card";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { formatDate, formatMoney } from "@/shared/lib";
@@ -33,7 +33,7 @@ function useCardPageVm() {
: 1;
const card = useBankCard(cardId);
const { updateCard } = useBankMutations();
const updateCard = useUpdateBankCard();
const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true);
@@ -69,7 +69,9 @@ function useCardPageVm() {
return {
color: isOutgoing ? "red" : "green",
editable:
Boolean(cards.data)
!history.isPlaceholderData
&& !card.data?.archivedAt
&& Boolean(cards.data)
&& !cards.data?.some(
c =>
(c.id === item.cardId || c.id === item.targetCardId)
@@ -94,27 +96,42 @@ function useCardPageVm() {
return {
archiveError,
archivePending: updateCard.isPending,
card,
cards,
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
card: card.data,
cardLoading: card.isLoading,
cardRefreshError: card.isError,
cardRefreshing: card.isFetching,
cards: cards.data,
cardsFeedback: cards.isError
? { cached: Boolean(cards.data), error: cards.error }
: undefined,
closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null),
deleting,
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
editingCard,
history,
history: {
changingPage: history.isPlaceholderData,
error: history.isError,
loading: history.isLoading,
page: history.data?.page,
refreshing: history.isFetching,
retry: () => void history.refetch(),
},
notFound: isApiErrorCode(
apiErrorCode(card.error),
ApiErrorCode.BankNotFound,
),
openCardEditor: () => setEditingCard(true),
openNewOperation: () => setOperation("new"),
operation,
page,
pageSize,
period,
retryCard: () => void card.refetch(),
retryCards: () => void cards.refetch(),
rows,
setDeleting,
setEditingCard,
setOperation,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
setPageSize: (value: null | string) => {
@@ -125,7 +142,13 @@ function useCardPageVm() {
if (value[0] && value[1])
setParameters({ from: value[0], limit: pageSize, to: value[1] });
},
summary,
summary: {
data: summary.data,
error: summary.isError,
fetching: summary.isFetching,
loading: summary.isLoading,
retry: () => void summary.refetch(),
},
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
@@ -8,7 +8,7 @@ import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) {
const { t } = useTranslation();
const card = vm.card.data;
const card = vm.card;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null
@@ -23,15 +23,13 @@ function useCardPagePresentationVm(vm: CardPageVm) {
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPageRef.current
= null;
if (pending.scope !== scope || vm.history.error) {
pendingPageRef.current = null;
return;
}
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
if (vm.history.changingPage || vm.history.page !== pending.page)
return;
const pagination = paginationRef.current;
@@ -43,18 +41,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
pendingPageRef.current = null;
}, [
scope,
vm.history.data,
vm.history.isPlaceholderData,
vm.history.isError,
vm.history.page,
vm.history.changingPage,
vm.history.error,
]);
const onEditCard = () => vm.setEditingCard(true);
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
const onToggleArchive = () => void vm.toggleArchive();
const retryCard = () => void vm.card.refetch();
const retryCards = () => void vm.cards.refetch();
const retrySummary = () => void vm.summary.refetch();
const onAddOperation = () => vm.setOperation("new");
const retryHistory = () => void vm.history.refetch();
const getControlProps: ComponentProps<
typeof Pagination
@@ -77,20 +67,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
};
return {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
};
}
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
useDeleteBankOperation,
} from "@/entities/bank-card";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
@@ -18,7 +18,7 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations();
const operationDeletion = useDeleteBankOperation();
const isPending
= !opened
@@ -13,8 +13,9 @@ import {
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
useCreateBankOperation,
useUpdateBankOperation,
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
@@ -49,7 +50,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation();
const methods = useForm<OperationValues>({
defaultValues: {
@@ -116,12 +118,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
await updateOperation.mutateAsync({
body: { ...body, version },
id: props.operation.id,
});
else
await mutation.createOperation.mutateAsync({
await operationCreation.mutateAsync({
id: intent,
...body,
});