refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) (#153)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 8m19s

This commit was merged in pull request #153.
This commit is contained in:
2026-11-04 21:14:29 +03:00
parent 2d070ce2b5
commit 2db39f25d9
30 changed files with 194 additions and 202 deletions
+6 -1
View File
@@ -25,7 +25,11 @@ const readability = {
) )
context.report({ messageId: "schema", node }); context.report({ messageId: "schema", node });
}, },
FunctionDeclaration: checkParameters, FunctionDeclaration(node) {
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters, FunctionExpression: checkParameters,
ImportDeclaration(node) { ImportDeclaration(node) {
if ( if (
@@ -95,6 +99,7 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.", schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.", svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.", union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
}, },
schema: [], schema: [],
type: "suggestion", type: "suggestion",
@@ -1,6 +1,5 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model"; import { useCurrentRoute } from "@/app/model";
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock"; import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation(); useAppDocumentTitle(props.title);
useDocumentTitle(`${props.title} — ${t("appName")}`);
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -1,12 +1,12 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => { const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props; const { authenticated } = props;
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`); useAppDocumentTitle(t("routeError.title"));
const to = authenticated ? "/accounts" : "/login"; const to = authenticated ? "/accounts" : "/login";
return ( return (
+17 -7
View File
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs"; import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
stock, stock,
} = useCurrentRoute(); } = useCurrentRoute();
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />; if (bond) {
if (stock) return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; }
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section)) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
/> />
</Stack> </Stack>
); );
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (card) {
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
}
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
); );
}; };
export { BreadcrumbTrail }; export { BreadcrumbTrail, type Crumb };
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -0,0 +1,48 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useStock } from "@/entities/stock";
import type { Crumb } from "./BreadcrumbTrail";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type EntityBreadcrumbsProps = {
identifier: string;
kind:
| "bond"
| "card"
| "stock";
};
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
const { identifier, kind } = props;
const { t } = useTranslation();
const bond = useBond(kind === "bond" ? identifier : "");
const card = useBankCard(kind === "card" ? identifier : "");
const stock = useStock(kind === "stock" ? identifier : "");
let parent: Crumb;
let name: string;
if (kind === "bond") {
parent = { label: t("bond.searchTitle"), to: "/bonds" };
name = bond.data?.name ?? t("bond.instrument");
}
else if (kind === "stock") {
parent = { label: t("stock.searchTitle"), to: "/stocks" };
name = stock.data?.name ?? t("stock.share");
}
else {
parent = { label: t("bank.title"), to: "/cards" };
name = card.data?.name ?? t("bank.card");
}
return <BreadcrumbTrail items={[parent, { label: name }]} />;
};
export { EntityBreadcrumbs };
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
@@ -4,16 +4,16 @@ import {
TbankConnectionError, TbankConnectionError,
TbankConnectionLoading, TbankConnectionLoading,
} from "./TbankConnectionSection.view"; } from "./TbankConnectionSection.view";
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm"; import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => { const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel(); const vm = useTbankConnectionSectionVm();
if (viewModel.state === "loading") return <TbankConnectionLoading />; if (vm.state === "loading") return <TbankConnectionLoading />;
return viewModel.state === "error" return vm.state === "error"
? <TbankConnectionError retry={viewModel.retry} /> ? <TbankConnectionError retry={vm.retry} />
: <TbankConnectionSectionLoaded {...viewModel} />; : <TbankConnectionSectionLoaded {...vm} />;
}; };
export { TbankConnectionSection }; export { TbankConnectionSection };
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionViewModel() { function useTbankConnectionSectionVm() {
const status = useAuthenticatedQuery({ const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
}); });
@@ -22,4 +22,4 @@ function useTbankConnectionViewModel() {
}; };
} }
export { useTbankConnectionViewModel }; export { useTbankConnectionSectionVm };
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), { const { result } = renderHook(() => useAccountEventsPageVm(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}> <MemoryRouter initialEntries={["?from=2099-02-01"]}>
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
() => useAccountEventsPageViewModel(), () => useAccountEventsPageVm(),
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -361,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), { const { result } = renderHook(() => useAccountEventsPageVm(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/accounts/all/events"]}> <MemoryRouter initialEntries={["/accounts/all/events"]}>
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => { const AccountEventsPage: FC = () => {
const viewModel = useAccountEventsPageViewModel(); const vm = useAccountEventsPageVm();
if (viewModel.state === "loading") return <AccountEventsPageLoading />; if (vm.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error" return vm.state === "error"
? <AccountEventsPageError {...viewModel} /> ? <AccountEventsPageError {...vm} />
: <AccountEventsPageLoaded {...viewModel} />; : <AccountEventsPageLoaded {...vm} />;
}; };
export { AccountEventsPage }; export { AccountEventsPage };
@@ -39,7 +39,7 @@ type AccountEventsPageViewModel
} }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageVm(): AccountEventsPageViewModel {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const { 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 { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage"; import { AccountIncomePage } from "./AccountIncomePage";
import { import {
useAccountIncomePageViewModel, useAccountIncomePageVm,
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
@@ -171,7 +171,7 @@ describe("AccountIncomePage", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageViewModel(), { const { result } = renderHook(() => useAccountIncomePageVm(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}> <MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm"; import { useAccountIncomePageVm } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => { const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel(); const vm = useAccountIncomePageVm();
if (viewModel.state === "loading") return <AccountIncomePageLoading />; if (vm.state === "loading") return <AccountIncomePageLoading />;
return viewModel.state === "error" return vm.state === "error"
? <AccountIncomePageError {...viewModel} /> ? <AccountIncomePageError {...vm} />
: <AccountIncomePageLoaded {...viewModel} />; : <AccountIncomePageLoaded {...vm} />;
}; };
export { AccountIncomePage }; export { AccountIncomePage };
@@ -43,7 +43,7 @@ type AccountIncomePageViewModel
} }
| { state: "loading" }; | { state: "loading" };
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams(); const [searchParameters, setSearchParameters] = useSearchParams();
@@ -146,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
export { export {
type AccountIncomePageViewModel, type AccountIncomePageViewModel,
useAccountIncomePageViewModel, useAccountIncomePageVm,
}; };
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => { const AccountOperationsPage: FC = () => {
const viewModel = useAccountOperationsPageVm(); const vm = useAccountOperationsPageVm();
if (viewModel.state === "loading") { if (vm.state === "loading") {
return <AccountOperationsPageLoading />; return <AccountOperationsPageLoading />;
} }
return viewModel.state === "error" return vm.state === "error"
? <AccountOperationsPageError {...viewModel} /> ? <AccountOperationsPageError {...vm} />
: <AccountOperationsPageLoaded {...viewModel} />; : <AccountOperationsPageLoaded {...vm} />;
}; };
export { AccountOperationsPage }; export { AccountOperationsPage };
@@ -1,48 +1,48 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountPageViewModel } from "./AccountPage.vm"; import { useAccountPageVm } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
const AccountPage: FC = () => { const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel(); const vm = useAccountPageVm();
if (viewModel.state === "loading") return <AccountPageLoading />; if (vm.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />; if (vm.state === "error") return <AccountPageError {...vm} />;
if (viewModel.state === "empty") if (vm.state === "empty")
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={viewModel.account} account={vm.account}
accountId={viewModel.accountId} accountId={vm.accountId}
incomeSummary={viewModel.incomeSummary} incomeSummary={vm.incomeSummary}
readRetry={viewModel.readRetry} readRetry={vm.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={vm.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={vm.recentOperations}
source={viewModel.source} source={vm.source}
/> />
); );
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={vm.account}
accountId={viewModel.accountId} accountId={vm.accountId}
concentration={viewModel.concentration} concentration={vm.concentration}
currency={viewModel.currency} currency={vm.currency}
incomeSummary={viewModel.incomeSummary} incomeSummary={vm.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange} onCurrencyChange={vm.onCurrencyChange}
onSliceChange={viewModel.onSliceChange} onSliceChange={vm.onSliceChange}
onTabChange={viewModel.onTabChange} onTabChange={vm.onTabChange}
partial={viewModel.partial} partial={vm.partial}
portfolio={viewModel.portfolio} portfolio={vm.portfolio}
positionSections={viewModel.positionSections} positionSections={vm.positionSections}
readRetry={viewModel.readRetry} readRetry={vm.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={vm.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={vm.recentOperations}
slice={viewModel.slice} slice={vm.slice}
source={viewModel.source} source={vm.source}
tab={viewModel.tab} tab={vm.tab}
unrealizedReturns={viewModel.unrealizedReturns} unrealizedReturns={vm.unrealizedReturns}
/> />
); );
}; };
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { portfolio } from "../../model/positions.fixtures"; import { portfolio } from "../../model/positions.fixtures";
import { useAccountPageViewModel } from "./AccountPage.vm"; import { useAccountPageVm } from "./AccountPage.vm";
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
); );
} }
describe("useAccountPageViewModel", () => { describe("useAccountPageVm", () => {
it("prepares recent operations and events state for presentation views", async () => { it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth(); configureTestAuth();
mockLoadedAccountPage(); mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => {
), ),
); );
const { result } = renderHook(() => useAccountPageViewModel(), { const { result } = renderHook(() => useAccountPageVm(), {
wrapper: createWrapper(), 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(), 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(), wrapper: createWrapper(),
}); });
@@ -77,7 +77,7 @@ type AccountPageViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageVm(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -199,4 +199,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
}; };
} }
export { useAccountPageViewModel }; export { useAccountPageVm };
@@ -1,22 +1,22 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountsPageViewModel } from "./AccountsPage.vm"; import { useAccountsPageVm } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view"; import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view"; import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => { const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel(); const vm = useAccountsPageVm();
if (viewModel.state === "loading") return <AccountsPageLoading />; if (vm.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />; if (vm.state === "error") return <AccountsPageError {...vm} />;
if (viewModel.state === "empty") return <AccountsPageEmpty />; if (vm.state === "empty") return <AccountsPageEmpty />;
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={vm.accounts}
meta={viewModel.meta} meta={vm.meta}
readRetry={viewModel.readRetry} readRetry={vm.readRetry}
/> />
); );
}; };
@@ -30,7 +30,7 @@ function isAccountsAccessFailure(code?: string) {
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected); || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
} }
function useAccountsPageViewModel(): AccountsPageViewModel { function useAccountsPageVm(): AccountsPageViewModel {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]); 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 type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth"; import { AuthPageLayout, LoginForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const LoginPage: FC = () => { const LoginPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${t("auth.login")} — ${t("appName")}`); useAppDocumentTitle(t("auth.login"));
return ( return (
<AuthPageLayout title={t("auth.login")}> <AuthPageLayout title={t("auth.login")}>
+3 -2
View File
@@ -1,14 +1,15 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { useAppDocumentTitle } from "@/shared/ui";
const NotFoundPage: FC = () => { const NotFoundPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`); useAppDocumentTitle(t("notFound.title"));
return ( return (
<Stack> <Stack>
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth"; import { AuthPageLayout, RegisterForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const RegisterPage: FC = () => { const RegisterPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(`${t("auth.register")} — ${t("appName")}`); useAppDocumentTitle(t("auth.register"));
return ( return (
<AuthPageLayout title={t("auth.register")}> <AuthPageLayout title={t("auth.register")}>
@@ -1,13 +1,24 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection"; import { TbankConnectionSection } from "@/features/tbank-connection";
import { useSettingsPageVm } from "./SettingsPage.vm";
const SettingsPage: FC = () => { 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 ( return (
<Stack> <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 };
+1
View File
@@ -11,3 +11,4 @@ export {
type ReadRetryProps, type ReadRetryProps,
} from "./ReadRetry/ReadRetry"; } from "./ReadRetry/ReadRetry";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
export { useAppDocumentTitle } from "./useAppDocumentTitle";
+13
View File
@@ -0,0 +1,13 @@
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
/**
* Sets `document.title` to `<title> — <appName>`.
* Owner of the "page title + app name suffix" formatting rule.
*/
function useAppDocumentTitle(title: string) {
const { t } = useTranslation();
useDocumentTitle(`${title} — ${t("appName")}`);
}
export { useAppDocumentTitle };