refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143)
Frontend verify / commits (pull_request) Successful in 32s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m51s

This commit is contained in:
2026-11-04 18:01:20 +03:00
parent e2d143df46
commit ecc1bd74ef
30 changed files with 194 additions and 202 deletions
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
const Navigation: FC = () => {
const location = useLocation();
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
const { result } = renderHook(() => useAccountEventsPageVm(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel(),
() => useAccountEventsPageVm(),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -361,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
const { result } = renderHook(() => useAccountEventsPageVm(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/accounts/all/events"]}>
@@ -1,17 +1,17 @@
import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const viewModel = useAccountEventsPageViewModel();
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
const vm = useAccountEventsPageVm();
if (vm.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error"
? <AccountEventsPageError {...viewModel} />
: <AccountEventsPageLoaded {...viewModel} />;
return vm.state === "error"
? <AccountEventsPageError {...vm} />
: <AccountEventsPageLoaded {...vm} />;
};
export { AccountEventsPage };
@@ -39,7 +39,7 @@ type AccountEventsPageViewModel
}
| { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
function useAccountEventsPageVm(): AccountEventsPageViewModel {
const { accountId = "" } = useParams();
const {
@@ -136,4 +136,4 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
};
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
export { type AccountEventsPageViewModel, useAccountEventsPageVm };
@@ -34,7 +34,7 @@ import { server } from "@/test/server";
import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage";
import {
useAccountIncomePageViewModel,
useAccountIncomePageVm,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
@@ -171,7 +171,7 @@ describe("AccountIncomePage", () => {
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
const { result } = renderHook(() => useAccountIncomePageVm(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
@@ -1,17 +1,17 @@
import type { FC } from "react";
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { useAccountIncomePageVm } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
const vm = useAccountIncomePageVm();
if (vm.state === "loading") return <AccountIncomePageLoading />;
return viewModel.state === "error"
? <AccountIncomePageError {...viewModel} />
: <AccountIncomePageLoaded {...viewModel} />;
return vm.state === "error"
? <AccountIncomePageError {...vm} />
: <AccountIncomePageLoaded {...vm} />;
};
export { AccountIncomePage };
@@ -43,7 +43,7 @@ type AccountIncomePageViewModel
}
| { state: "loading" };
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams();
@@ -146,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
export {
type AccountIncomePageViewModel,
useAccountIncomePageViewModel,
useAccountIncomePageVm,
};
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => {
const viewModel = useAccountOperationsPageVm();
const vm = useAccountOperationsPageVm();
if (viewModel.state === "loading") {
if (vm.state === "loading") {
return <AccountOperationsPageLoading />;
}
return viewModel.state === "error"
? <AccountOperationsPageError {...viewModel} />
: <AccountOperationsPageLoaded {...viewModel} />;
return vm.state === "error"
? <AccountOperationsPageError {...vm} />
: <AccountOperationsPageLoaded {...vm} />;
};
export { AccountOperationsPage };
@@ -1,48 +1,48 @@
import type { FC } from "react";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { useAccountPageVm } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
const vm = useAccountPageVm();
if (vm.state === "loading") return <AccountPageLoading />;
if (vm.state === "error") return <AccountPageError {...vm} />;
if (vm.state === "empty")
return (
<AccountPageEmpty
account={viewModel.account}
accountId={viewModel.accountId}
incomeSummary={viewModel.incomeSummary}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
source={viewModel.source}
account={vm.account}
accountId={vm.accountId}
incomeSummary={vm.incomeSummary}
readRetry={vm.readRetry}
recentEvents={vm.recentEvents}
recentOperations={vm.recentOperations}
source={vm.source}
/>
);
return (
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
concentration={viewModel.concentration}
currency={viewModel.currency}
incomeSummary={viewModel.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={viewModel.onSliceChange}
onTabChange={viewModel.onTabChange}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
slice={viewModel.slice}
source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
account={vm.account}
accountId={vm.accountId}
concentration={vm.concentration}
currency={vm.currency}
incomeSummary={vm.incomeSummary}
onCurrencyChange={vm.onCurrencyChange}
onSliceChange={vm.onSliceChange}
onTabChange={vm.onTabChange}
partial={vm.partial}
portfolio={vm.portfolio}
positionSections={vm.positionSections}
readRetry={vm.readRetry}
recentEvents={vm.recentEvents}
recentOperations={vm.recentOperations}
slice={vm.slice}
source={vm.source}
tab={vm.tab}
unrealizedReturns={vm.unrealizedReturns}
/>
);
};
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { portfolio } from "../../model/positions.fixtures";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { useAccountPageVm } from "./AccountPage.vm";
function configureTestAuth() {
configureAuthClient({
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
);
}
describe("useAccountPageViewModel", () => {
describe("useAccountPageVm", () => {
it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), {
const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(),
});
@@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => {
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(),
});
@@ -194,7 +194,7 @@ it("keeps overview sections independent when their source data is incomplete", a
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(),
});
@@ -232,7 +232,7 @@ it("keeps overview sections loaded without exposing technical retry", async () =
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(),
});
@@ -77,7 +77,7 @@ type AccountPageViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" };
function useAccountPageViewModel(): AccountPageViewModel {
function useAccountPageVm(): AccountPageViewModel {
const { accountId } = useParams();
const { t } = useTranslation();
@@ -199,4 +199,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
};
}
export { useAccountPageViewModel };
export { useAccountPageVm };
@@ -1,22 +1,22 @@
import type { FC } from "react";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { useAccountsPageVm } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty") return <AccountsPageEmpty />;
const vm = useAccountsPageVm();
if (vm.state === "loading") return <AccountsPageLoading />;
if (vm.state === "error") return <AccountsPageError {...vm} />;
if (vm.state === "empty") return <AccountsPageEmpty />;
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
meta={viewModel.meta}
readRetry={viewModel.readRetry}
accounts={vm.accounts}
meta={vm.meta}
readRetry={vm.readRetry}
/>
);
};
@@ -30,7 +30,7 @@ function isAccountsAccessFailure(code?: string) {
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountsPageViewModel(): AccountsPageViewModel {
function useAccountsPageVm(): AccountsPageViewModel {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
@@ -73,4 +73,4 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
};
}
export { useAccountsPageViewModel };
export { useAccountsPageVm };
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const LoginPage: FC = () => {
const { t } = useTranslation();
useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
useAppDocumentTitle(t("auth.login"));
return (
<AuthPageLayout title={t("auth.login")}>
+3 -2
View File
@@ -1,14 +1,15 @@
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useAppDocumentTitle } from "@/shared/ui";
const NotFoundPage: FC = () => {
const { t } = useTranslation();
useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
useAppDocumentTitle(t("notFound.title"));
return (
<Stack>
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const RegisterPage: FC = () => {
const { t } = useTranslation();
useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
useAppDocumentTitle(t("auth.register"));
return (
<AuthPageLayout title={t("auth.register")}>
@@ -1,13 +1,24 @@
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection";
import { useSettingsPageVm } from "./SettingsPage.vm";
const SettingsPage: FC = () => {
const { onLogout, t } = useSettingsPageVm();
const { t } = useTranslation();
const { logout } = useAuth();
// eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel.
const onLogout = async () => {
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return (
<Stack>
@@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
function useSettingsPageVm() {
const { t } = useTranslation();
const { logout } = useAuth();
const onLogout = async () => {
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return { onLogout, t };
}
export { useSettingsPageVm };