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 });
},
FunctionDeclaration: checkParameters,
FunctionDeclaration(node) {
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters,
ImportDeclaration(node) {
if (
@@ -95,6 +99,7 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
},
schema: [],
type: "suggestion",
@@ -1,6 +1,5 @@
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model";
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
useAppDocumentTitle(props.title);
};
type AccountDocumentTitleProps = {
@@ -1,12 +1,12 @@
import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{
authenticated: boolean;
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props;
const { t } = useTranslation();
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
useAppDocumentTitle(t("routeError.title"));
const to = authenticated ? "/accounts" : "/login";
return (
+17 -7
View File
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
stock,
} = useCurrentRoute();
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (bond) {
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
}
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section))
return (
<Stack gap={0} mb="md">
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
/>
</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 (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,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel();
if (viewModel.state === "loading") return <TbankConnectionLoading />;
const vm = useTbankConnectionSectionVm();
if (vm.state === "loading") return <TbankConnectionLoading />;
return viewModel.state === "error"
? <TbankConnectionError retry={viewModel.retry} />
: <TbankConnectionSectionLoaded {...viewModel} />;
return vm.state === "error"
? <TbankConnectionError retry={vm.retry} />
: <TbankConnectionSectionLoaded {...vm} />;
};
export { TbankConnectionSection };
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionViewModel() {
function useTbankConnectionSectionVm() {
const status = useAuthenticatedQuery({
...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 { 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 };
+1
View File
@@ -11,3 +11,4 @@ export {
type ReadRetryProps,
} from "./ReadRetry/ReadRetry";
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 };