refactor: remove redundant page view models
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m35s

This commit is contained in:
2026-09-28 20:34:33 +03:00
parent 94adbafa34
commit fb3a5a6eaa
10 changed files with 109 additions and 313 deletions
@@ -18,7 +18,7 @@ import {
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
@@ -47,13 +47,40 @@ const Navigation: FC = () => {
);
};
function renderEvents(accountId: string, searches: string[]) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={searches.map(search =>
`/accounts/${accountId}/events${search}`,
)}
initialIndex={0}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
beforeEach(() => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
});
it("keeps reads and sync disabled without an account scope", async () => {
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
@@ -87,11 +114,6 @@ describe("Upcoming event date filters", () => {
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
@@ -153,11 +175,6 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
http.get(
@@ -169,31 +186,13 @@ describe("Upcoming event date filters", () => {
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map(search => `/accounts/${accountId}/events${search}`)}
initialIndex={0}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderEvents(accountId, [
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
]);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
@@ -221,11 +220,6 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
@@ -235,24 +229,7 @@ describe("Upcoming event date filters", () => {
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderEvents(accountId, ["?from=2099-02-01"]);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
@@ -263,11 +240,6 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
http.get(
@@ -286,26 +258,7 @@ describe("Upcoming event date filters", () => {
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]);
const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2099 – 28.02.2099");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
@@ -11,13 +11,11 @@ import { isValidPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftFrom: null | string;
draftTo: null | string;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
lastSuccessfulAt?: null | string;
onApply: () => void;
onDraftFromChange: (value: null | string) => void;
onDraftToChange: (value: null | string) => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onReset: () => void;
partial?: boolean;
refreshError: boolean;
@@ -47,8 +45,12 @@ function useAccountEventsPageViewModel(
? urlTo
: null;
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
const [draftPeriod, setDraftPeriod] = useState<[
null | string,
null | string,
]>([appliedFrom, appliedTo]);
const [draftFrom, draftTo] = draftPeriod;
useEffect(() => {
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
@@ -64,8 +66,7 @@ function useAccountEventsPageViewModel(
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
setPreviousPeriod([appliedFrom, appliedTo]);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftPeriod([appliedFrom, appliedTo]);
}
const events = useBrokerageEvents(accountId, {
@@ -86,8 +87,7 @@ function useAccountEventsPageViewModel(
if (events.isLoading) return { state: "loading" };
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftPeriod([null, null]);
setSearchParameters({});
};
@@ -105,8 +105,7 @@ function useAccountEventsPageViewModel(
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
draftFrom,
draftTo,
draftPeriod,
events: events.data.events,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
onApply: () => {
@@ -116,8 +115,7 @@ function useAccountEventsPageViewModel(
...(draftTo && { to: draftTo }),
});
},
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onPeriodChange: setDraftPeriod,
onReset,
partial: events.meta?.availability !== "complete",
refreshError: events.isError || sync.sourceError || sync.transportError,
@@ -10,6 +10,7 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
@@ -17,7 +18,6 @@ import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
@@ -56,7 +56,7 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props);
const { t } = useTranslation();
return (
<Stack gap="lg">
@@ -100,13 +100,13 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={onPeriodChange}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={[props.draftFrom, props.draftTo]}
value={props.draftPeriod}
valueFormat="DD.MM.YYYY"
/>
</Group>
@@ -1,24 +0,0 @@
import { useTranslation } from "react-i18next";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) {
const { t } = useTranslation();
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
};
return { onPeriodChange, t };
}
export { useAccountEventsPageLoadedVm };
@@ -13,22 +13,22 @@ import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { isValidPeriod } from "@/shared/lib";
const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel
= | {
canApply: boolean;
draftCategories: OperationCategory[];
draftFrom: null | string;
draftTo: null | string;
draftPeriod: [null | string, null | string];
hasNext: boolean;
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
onApply: () => void;
onDraftCategoriesChange: (value: OperationCategory[]) => void;
onDraftFromChange: (value: null | string) => void;
onDraftToChange: (value: null | string) => void;
onCategoriesChange: (value: string[]) => void;
onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
@@ -64,8 +64,12 @@ const useAccountOperationsPageVm = (
(searchParameters.get("categories") ?? "").split(",").includes(category),
);
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
const [draftPeriod, setDraftPeriod] = useState<[
null | string,
null | string,
]>([appliedFrom, appliedTo]);
const [draftFrom, draftTo] = draftPeriod;
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
@@ -100,15 +104,8 @@ const useAccountOperationsPageVm = (
if (previousSearch !== searchParameters) {
setPreviousSearch(searchParameters);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter(category =>
(searchParameters.get("categories") ?? "")
.split(",")
.includes(category),
),
);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories(appliedCategories);
}
const firstPageQuery = {
@@ -154,8 +151,7 @@ const useAccountOperationsPageVm = (
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftPeriod([null, null]);
setDraftCategories([]);
setSearchParameters({});
resetCursors();
@@ -182,8 +178,7 @@ const useAccountOperationsPageVm = (
return {
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories,
draftFrom,
draftTo,
draftPeriod,
hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: cursors.length > 0,
historyLoading:
@@ -202,13 +197,16 @@ const useAccountOperationsPageVm = (
});
resetCursors();
},
onDraftCategoriesChange: setDraftCategories,
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onCategoriesChange: values => setDraftCategories(
values.filter((value): value is OperationCategory =>
operationCategorySet.has(value),
),
),
onNext: () => {
if (nextCursor)
setPagination({ cursors: [...cursors, nextCursor], scopeKey });
},
onPeriodChange: setDraftPeriod,
onPrevious: () =>
setPagination({ cursors: cursors.slice(0, -1), scopeKey }),
onReset,
@@ -14,13 +14,11 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
import { useAccountOperationsPageLoadedVm } from "./AccountOperationsPageLoaded.vm";
type AccountOperationsPageLoadedProps = Extract<
AccountOperationsPageViewModel,
{ state: "loaded" }
@@ -56,22 +54,12 @@ const AccountOperationsPageLoadedOperations: FC<
const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
props,
) => {
const {
canApply,
categoryData,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
onApply,
onCategoriesChange,
onNext,
onPeriodChange,
onPrevious,
onReset,
t,
} = useAccountOperationsPageLoadedVm(props);
const { t } = useTranslation();
const categoryData = operationCategories.map(category => ({
label: t(`ledger.category.${category}`),
value: category,
}));
return (
<Stack gap="lg">
@@ -115,13 +103,13 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={onPeriodChange}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={[draftFrom, draftTo]}
value={props.draftPeriod}
valueFormat="DD.MM.YYYY"
/>
</Group>
@@ -131,30 +119,30 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
data={categoryData}
label={t("ledger.categories")}
onChange={onCategoriesChange}
onChange={props.onCategoriesChange}
placeholder={t("ledger.categoriesPlaceholder")}
value={draftCategories}
value={props.draftCategories}
/>
<Group>
<Button disabled={!canApply} onClick={onApply}>
<Button disabled={!props.canApply} onClick={props.onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={onReset} variant="default">
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
<AccountOperationsPageLoadedOperations {...props} />
{!!(hasNext || hasPrevious) && (
{!!(props.hasNext || props.hasPrevious) && (
<Group justify="space-between">
<Button disabled={!hasPrevious} onClick={onPrevious}>
<Button disabled={!props.hasPrevious} onClick={props.onPrevious}>
{t("ledger.previous")}
</Button>
<Button disabled={!hasNext} onClick={onNext}>
<Button disabled={!props.hasNext} onClick={props.onNext}>
{t("ledger.next")}
</Button>
</Group>
@@ -1,78 +0,0 @@
import { useTranslation } from "react-i18next";
import {
operationCategories,
type OperationCategory,
} from "@/entities/broker-operation";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
AccountOperationsPageViewModel,
{ state: "loaded" }
>;
const operationCategorySet = new Set<string>(operationCategories);
function fromMultiSelectValue(value: string[]) {
return value.filter((item): item is OperationCategory =>
operationCategorySet.has(item),
);
}
function useAccountOperationsPageLoadedVm(
props: AccountOperationsPageLoadedProps,
) {
const {
canApply,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
onApply,
onDraftCategoriesChange,
onDraftFromChange,
onDraftToChange,
onNext,
onPrevious,
onReset,
} = props;
const { t } = useTranslation();
const categoryData = operationCategories.map(category => ({
label: t(`ledger.category.${category}`),
value: category,
}));
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
onDraftFromChange(from);
onDraftToChange(to);
};
const onCategoriesChange: (value: string[]) => void = value =>
onDraftCategoriesChange(fromMultiSelectValue(value));
return {
canApply,
categoryData,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
onApply,
onCategoriesChange,
onNext,
onPeriodChange,
onPrevious,
onReset,
t,
};
}
export { useAccountOperationsPageLoadedVm };
@@ -27,14 +27,12 @@ import {
import type { CardsPageVm } from "./CardsPage.vm";
import classes from "./CardsPage.module.css";
import { useCardsPageViewVm } from "./CardsPageView.vm";
type CardsPageViewProps = { vm: CardsPageVm };
const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
const { t } = useTranslation();
const { vm } = props;
const { onAddCard } = useCardsPageViewVm(props);
if (vm.items) {
if (vm.items.length > 0) {
@@ -59,7 +57,7 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
return (
<StateView
action={<Button onClick={onAddCard}>{t("bank.addCard")}</Button>}
action={<Button onClick={vm.onAddCard}>{t("bank.addCard")}</Button>}
text={text}
title={t("bank.empty")}
/>
@@ -80,16 +78,8 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
};
const CardsPageView: FC<CardsPageViewProps> = (props) => {
const {
onAddCard,
onArchiveChange,
onCategories,
retryCards,
retryCategories,
retrySummary,
t,
vm,
} = useCardsPageViewVm(props);
const { vm } = props;
const { t } = useTranslation();
return (
<Stack gap="lg">
@@ -98,7 +88,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<ActionIcon
aria-label={t("bank.addCard")}
onClick={onAddCard}
onClick={vm.onAddCard}
size="lg"
title={t("bank.addCard")}
variant="light"
@@ -110,7 +100,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<Switch
checked={vm.archive}
label={t("bank.showArchive")}
onChange={onArchiveChange}
onChange={vm.onArchiveChange}
/>
<CardsPageViewCards {...props} />
@@ -119,7 +109,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={retryCards} variant="default">
<Button onClick={vm.retryCards} variant="default">
{t("bank.retry")}
</Button>
</Group>
@@ -134,7 +124,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<Button
color="gray"
leftSection={<CategoriesIcon size={18} strokeWidth="1.7" />}
onClick={onCategories}
onClick={vm.onCategories}
variant="subtle"
>
{t("bank.categories")}
@@ -148,7 +138,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={retrySummary}
retry={vm.retrySummary}
/>
{vm.categories.isError && (
@@ -156,7 +146,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
cached={Boolean(vm.categories.data)}
error={vm.categories.error}
loadingError
retry={retryCategories}
retry={vm.retryCategories}
/>
)}
</Stack>
+9 -3
View File
@@ -1,3 +1,5 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
import { useSearchParams } from "react-router";
@@ -32,10 +34,14 @@ function useCardsPageVm() {
to: `/cards/${card.id}?${new URLSearchParams(period)}`,
})),
managingCategories,
onAddCard: () => setAdding(true),
onArchiveChange: (event: ChangeEvent<HTMLInputElement>) =>
setArchive(event.currentTarget.checked),
onCategories: () => setManagingCategories(true),
period,
setAdding,
setArchive,
setManagingCategories,
retryCards: () => void cards.refetch(),
retryCategories: () => void categories.refetch(),
retrySummary: () => void summary.refetch(),
setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
},
@@ -1,35 +0,0 @@
import type { ComponentProps } from "react";
import { Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { CardsPageVm } from "./CardsPage.vm";
type CardsPageViewProps = { vm: CardsPageVm };
function useCardsPageViewVm(props: CardsPageViewProps) {
const { vm } = props;
const { t } = useTranslation();
const onAddCard = () => vm.setAdding(true);
const onArchiveChange: ComponentProps<typeof Switch>["onChange"] = event =>
vm.setArchive(event.currentTarget.checked);
const retryCards = () => void vm.cards.refetch();
const onCategories = () => vm.setManagingCategories(true);
const retrySummary = () => void vm.summary.refetch();
const retryCategories = () => void vm.categories.refetch();
return {
onAddCard,
onArchiveChange,
onCategories,
retryCards,
retryCategories,
retrySummary,
t,
vm,
};
}
export { useCardsPageViewVm };