From fb3a5a6eaa0223a15ea2c9f585a06459371f0a25 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 20:34:33 +0300 Subject: [PATCH] refactor: remove redundant page view models --- .../AccountEventsPage.filters.test.tsx | 123 ++++++------------ .../AccountEventsPage/AccountEventsPage.vm.ts | 26 ++-- .../AccountEventsPageLoaded.view.tsx | 8 +- .../components/AccountEventsPageLoaded.vm.ts | 24 ---- .../AccountOperationsPage.vm.ts | 44 +++---- .../AccountOperationsPageLoaded.view.tsx | 44 +++---- .../AccountOperationsPageLoaded.vm.ts | 78 ----------- .../cards/ui/CardsPage/CardsPage.view.tsx | 28 ++-- src/pages/cards/ui/CardsPage/CardsPage.vm.ts | 12 +- .../cards/ui/CardsPage/CardsPageView.vm.ts | 35 ----- 10 files changed, 109 insertions(+), 313 deletions(-) delete mode 100644 src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts delete mode 100644 src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts delete mode 100644 src/pages/cards/ui/CardsPage/CardsPageView.vm.ts diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 4968f31..fbc7a51 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -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( + + + + `/accounts/${accountId}/events${search}`, + )} + initialIndex={0} + > + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); +} + 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( - - - `/accounts/${accountId}/events${search}`)} - initialIndex={0} - > - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + 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( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + 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( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + 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(); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 20d0a67..e839d83 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -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(appliedFrom); - const [draftTo, setDraftTo] = useState(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, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 8956252..362a84f 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -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 = ( }; const AccountEventsPageLoaded: FC = (props) => { - const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props); + const { t } = useTranslation(); return ( @@ -100,13 +100,13 @@ const AccountEventsPageLoaded: FC = (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" /> diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts deleted file mode 100644 index af03a32..0000000 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts +++ /dev/null @@ -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 }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index ed1676b..e3b3c6a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -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(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(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); + const [draftPeriod, setDraftPeriod] = useState<[ + null | string, + null | string, + ]>([appliedFrom, appliedTo]); + + const [draftFrom, draftTo] = draftPeriod; const [draftCategories, setDraftCategories] = useState(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, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index c981e79..af02e2d 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -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 = ( 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 ( @@ -115,13 +103,13 @@ const AccountOperationsPageLoaded: FC = ( 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" /> @@ -131,30 +119,30 @@ const AccountOperationsPageLoaded: FC = ( 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} /> - - - {!!(hasNext || hasPrevious) && ( + {!!(props.hasNext || props.hasPrevious) && ( - - diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts deleted file mode 100644 index 5ee01cc..0000000 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts +++ /dev/null @@ -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(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 }; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 1b1ea0b..a59d1f4 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -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 = (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 = (props) => { return ( {t("bank.addCard")}} + action={} text={text} title={t("bank.empty")} /> @@ -80,16 +78,8 @@ const CardsPageViewCards: FC = (props) => { }; const CardsPageView: FC = (props) => { - const { - onAddCard, - onArchiveChange, - onCategories, - retryCards, - retryCategories, - retrySummary, - t, - vm, - } = useCardsPageViewVm(props); + const { vm } = props; + const { t } = useTranslation(); return ( @@ -98,7 +88,7 @@ const CardsPageView: FC = (props) => { = (props) => { @@ -119,7 +109,7 @@ const CardsPageView: FC = (props) => { {t(vm.items ? "bank.refreshError" : "bank.error")} - @@ -134,7 +124,7 @@ const CardsPageView: FC = (props) => {