refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -10,16 +10,22 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
|
||||
import {
|
||||
AddIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
OperationIcon,
|
||||
RestoreIcon,
|
||||
} from "@wealthledger/icons";
|
||||
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
|
||||
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
|
||||
import { type FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
BankCardFace,
|
||||
BankFeedback,
|
||||
BankOperationIcon,
|
||||
bankOperationAppearance,
|
||||
BankPeriodPicker,
|
||||
BankSummaryView,
|
||||
} from "@/entities/bank-card";
|
||||
@@ -27,80 +33,33 @@ import {
|
||||
import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
import classes from "./CardPage.module.css";
|
||||
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
|
||||
|
||||
type CardPageViewProps = { vm: CardPageVm };
|
||||
const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
|
||||
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card.data;
|
||||
const paginationRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPageRef = useRef<null | {
|
||||
page: number;
|
||||
scope: string;
|
||||
top: number;
|
||||
}>(null);
|
||||
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingPageRef.current;
|
||||
if (!pending) return;
|
||||
if (pending.scope !== scope || vm.history.isError) {
|
||||
pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ null;
|
||||
return;
|
||||
}
|
||||
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
|
||||
return;
|
||||
const pagination = paginationRef.current;
|
||||
if (pagination) {
|
||||
const delta = pagination.getBoundingClientRect().top - pending.top;
|
||||
if (delta) window.scrollBy({ behavior: "instant", top: delta });
|
||||
}
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
|
||||
pendingPageRef.current = null;
|
||||
}, [
|
||||
scope,
|
||||
vm.history.data,
|
||||
vm.history.isPlaceholderData,
|
||||
vm.history.isError,
|
||||
]);
|
||||
if (!card) {
|
||||
if (vm.card.isLoading)
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={130} />
|
||||
<Skeleton h={160} />
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
<Button component={Link} to="/cards">
|
||||
{t("bank.back")}
|
||||
</Button>
|
||||
{!vm.notFound && (
|
||||
<Button onClick={() => void vm.card.refetch()}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
error
|
||||
text={t("bank.back")}
|
||||
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
|
||||
function renderHistory(): ReactNode {
|
||||
if (!card) return;
|
||||
if (vm.rows) {
|
||||
if (vm.rows.length > 0) {
|
||||
return vm.rows.map(row => (
|
||||
if (!card) return;
|
||||
|
||||
if (vm.rows) {
|
||||
if (vm.rows.length > 0) {
|
||||
return vm.rows.map((row) => {
|
||||
const details = row.otherCard
|
||||
? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
className={classes.operation}
|
||||
@@ -116,13 +75,14 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
onClick={() => vm.setOperation(row.item)}
|
||||
onClick={row.onEdit}
|
||||
size="sm"
|
||||
title={t("bank.edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t("bank.delete")}
|
||||
color="red"
|
||||
@@ -131,7 +91,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
onClick={() => vm.setDeleting(row.item)}
|
||||
onClick={row.onDelete}
|
||||
size="sm"
|
||||
title={t("bank.delete")}
|
||||
variant="subtle"
|
||||
@@ -141,22 +101,13 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</Group>
|
||||
)}
|
||||
color={row.color}
|
||||
details={
|
||||
(row.otherCard)
|
||||
? ((
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
details={details}
|
||||
icon={(
|
||||
<BankOperationIcon
|
||||
categoryName={row.item.categoryName}
|
||||
kind={row.item.kind}
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance(
|
||||
row.item.kind,
|
||||
row.item.categoryName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
initial={row.title[0]}
|
||||
@@ -167,33 +118,96 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
/>
|
||||
</section>
|
||||
));
|
||||
}
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
);
|
||||
});
|
||||
}
|
||||
if (vm.history.isLoading) {
|
||||
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
|
||||
if (vm.history.isLoading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (!card) {
|
||||
if (vm.card.isLoading)
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<LoadingSkeleton label={t("bank.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={130} />
|
||||
|
||||
<Skeleton h={160} />
|
||||
|
||||
<Skeleton h={90} />
|
||||
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const errorTitle = t(vm.notFound ? "bank.notFound" : "bank.error");
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
<Button component={Link} to="/cards">
|
||||
{t("bank.back")}
|
||||
</Button>
|
||||
|
||||
{!vm.notFound && <Button onClick={retryCard}>{t("bank.retry")}</Button>}
|
||||
</Group>
|
||||
)}
|
||||
error
|
||||
text={t("bank.back")}
|
||||
title={errorTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Stack>
|
||||
<Title className={classes.wrap} order={1}>
|
||||
{card.name}
|
||||
</Title>
|
||||
|
||||
<div className={classes.card}>
|
||||
<BankCardFace card={card} />
|
||||
|
||||
<Group className={classes.cardActions} gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.editCard")}
|
||||
onClick={() => vm.setEditingCard(true)}
|
||||
onClick={onEditCard}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={t("bank.editCard")}
|
||||
@@ -201,31 +215,20 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
||||
aria-label={archiveActionLabel}
|
||||
color="red"
|
||||
loading={vm.archivePending}
|
||||
onClick={() => void vm.toggleArchive()}
|
||||
onClick={onToggleArchive}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
||||
title={archiveActionLabel}
|
||||
variant="subtle"
|
||||
>
|
||||
{card.archivedAt
|
||||
? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="18"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
>
|
||||
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
|
||||
</svg>
|
||||
<RestoreIcon size={18} />
|
||||
)
|
||||
: (
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
@@ -233,46 +236,51 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
{!!(vm.archiveError) && ((
|
||||
<BankFeedback
|
||||
error={vm.archiveError}
|
||||
retry={() => void vm.card.refetch()}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!!vm.archiveError && (
|
||||
<BankFeedback error={vm.archiveError} retry={retryCard} />
|
||||
)}
|
||||
|
||||
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
||||
</Stack>
|
||||
|
||||
{vm.card.isError && (
|
||||
<Group role="alert">
|
||||
<Text>{t("bank.refreshError")}</Text>
|
||||
<Button onClick={() => void vm.card.refetch()}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={retryCard}>{t("bank.retry")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
||||
|
||||
{vm.cards.isError && (
|
||||
<BankFeedback
|
||||
cached={Boolean(vm.cards.data)}
|
||||
error={vm.cards.error}
|
||||
loadingError
|
||||
retry={() => void vm.cards.refetch()}
|
||||
retry={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={() => void vm.summary.refetch()}
|
||||
retry={retrySummary}
|
||||
/>
|
||||
|
||||
<Group align="end" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Title order={2}>{t("bank.operations")}</Title>
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t("bank.addOperation")}
|
||||
disabled={Boolean(card.archivedAt) || !vm.cards.data}
|
||||
onClick={() => vm.setOperation("new")}
|
||||
onClick={onAddOperation}
|
||||
size="lg"
|
||||
title={t("bank.addOperation")}
|
||||
variant="subtle"
|
||||
@@ -280,6 +288,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
<AddIcon size={20} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
data={[
|
||||
@@ -296,6 +305,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Stack
|
||||
aria-busy={vm.history.isFetching}
|
||||
className={classes.operations}
|
||||
@@ -303,16 +313,17 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
gap="lg"
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||
>
|
||||
{renderHistory()}
|
||||
<CardPageViewHistory {...props} />
|
||||
</Stack>
|
||||
|
||||
{vm.history.isError && (
|
||||
<Group role="alert">
|
||||
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
|
||||
<Button onClick={() => void vm.history.refetch()}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={retryHistory}>{t("bank.retry")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vm.history.data && vm.pageSize !== "all" && (
|
||||
<Group
|
||||
aria-label={t("bank.pagination")}
|
||||
@@ -323,23 +334,9 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
>
|
||||
<Pagination
|
||||
disabled={vm.history.isFetching}
|
||||
getControlProps={control => ({
|
||||
"aria-label": t(
|
||||
control === "previous" ? "bank.previous" : "bank.next",
|
||||
),
|
||||
})}
|
||||
getItemProps={page => ({
|
||||
"aria-label": t("bank.page", { page }),
|
||||
})}
|
||||
onChange={(page) => {
|
||||
if (paginationRef.current)
|
||||
pendingPageRef.current = {
|
||||
page,
|
||||
scope,
|
||||
top: paginationRef.current.getBoundingClientRect().top,
|
||||
};
|
||||
vm.setPage(page);
|
||||
}}
|
||||
getControlProps={getControlProps}
|
||||
getItemProps={getItemProps}
|
||||
onChange={onPageChange}
|
||||
siblings={0}
|
||||
total={vm.totalPages}
|
||||
value={vm.page}
|
||||
|
||||
Reference in New Issue
Block a user