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